diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 00000000..35049cbc
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+xtask = "run --package xtask --"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index c85f96ad..3620e8d0 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -80,3 +80,20 @@ jobs:
run: cargo binstall --no-confirm --no-symlinks cargo-deny
- run: cargo deny check
+
+ cargo-about:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Rust
+ run: rustup update stable && rustup default stable && rustup component add clippy
+
+ - name: Get cargo-binstall
+ run: |
+ curl -L https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -zxf - && mv cargo-binstall $HOME/.cargo/bin/
+
+ - name: Install required cargo addons
+ run: cargo binstall --no-confirm --no-symlinks cargo-about
+
+ - run: mkdir target && cargo about generate about.hbs > target/license.html
diff --git a/.github/workflows/mdbook.yml b/.github/workflows/mdbook.yml
new file mode 100644
index 00000000..7c357ece
--- /dev/null
+++ b/.github/workflows/mdbook.yml
@@ -0,0 +1,32 @@
+name: Mdbook
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: Install Rust
+ run: |
+ rustup set profile minimal
+ rustup toolchain install stable
+ rustup default stable
+ - name: Install latest mdbook
+ run: |
+ tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
+ url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
+ mkdir bin
+ curl -sSL $url | tar -xz --directory=bin
+ echo "$(pwd)/bin" >> $GITHUB_PATH
+ - name: Run tests
+ run: cd doc && mdbook test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a7ae791b..3ed637b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,7 +31,27 @@ defaults:
shell: bash
jobs:
- upload-assets:
+
+ cargo-about:
+ if: github.repository_owner == 'VorpalBlade' && (startsWith(github.event.release.name, 'paketkoll-v') || startsWith(github.event.release.name, 'konfigkoll-v'))
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Rust
+ run: rustup update stable && rustup default stable && rustup component add clippy
+ - name: Get cargo-binstall
+ run: |
+ curl -L https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -zxf - && mv cargo-binstall $HOME/.cargo/bin/
+ - name: Install required cargo addons
+ run: cargo binstall --no-confirm --no-symlinks cargo-about
+ - run: mkdir target && cargo about generate about.hbs > target/licenses.html
+ - name: Upload licenses.html
+ run: GITHUB_TOKEN="${token}" retry gh release upload "${tag}" target/licenses.html --clobber
+ env:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ tag: ${{ github.event.release.tag_name }}
+
+ upload-paketkoll:
name: ${{ matrix.target }}
if: github.repository_owner == 'VorpalBlade' && startsWith(github.event.release.name, 'paketkoll-v')
runs-on: ubuntu-22.04
@@ -63,7 +83,40 @@ jobs:
with:
subject-path: "${{ steps.upload-rust-binary-action.outputs.archive }}.*"
- upload-aur:
+ upload-konfigkoll:
+ name: ${{ matrix.target }}
+ if: github.repository_owner == 'VorpalBlade' && startsWith(github.event.release.name, 'konfigkoll-v')
+ runs-on: ubuntu-22.04
+ strategy:
+ matrix:
+ include:
+ - target: aarch64-unknown-linux-musl
+ - target: armv7-unknown-linux-musleabihf
+ - target: i686-unknown-linux-musl
+ - target: riscv64gc-unknown-linux-gnu
+ - target: x86_64-unknown-linux-musl
+ timeout-minutes: 60
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ - uses: taiki-e/install-action@cross
+ - uses: taiki-e/upload-rust-binary-action@v1.21.1
+ id: upload-rust-binary-action
+ with:
+ bin: konfigkoll, konfigkoll_rune
+ target: ${{ matrix.target }}
+ # Include version number.
+ archive: $bin-$tag-$target
+ token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Generate artifact attestation
+ uses: actions/attest-build-provenance@v1
+ with:
+ subject-path: "${{ steps.upload-rust-binary-action.outputs.archive }}.*"
+
+ upload-aur-paketkoll:
+ if: github.repository_owner == 'VorpalBlade' && startsWith(github.event.release.name, 'paketkoll-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -85,3 +138,27 @@ jobs:
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: New upstream release (automatic update from GitHub Actions)
+
+ #upload-aur-konfigkoll:
+ # if: github.repository_owner == 'VorpalBlade' && startsWith(github.event.release.name, 'konfigkoll-v')
+ # runs-on: ubuntu-latest
+ # steps:
+ # - uses: actions/checkout@v4
+ # - name: Get AUR repo
+ # run: git clone https://aur.archlinux.org/konfigkoll.git aur
+ # - name: Update PKGBUILD
+ # run: |
+ # sed -i '/^_pkgver/s/=.*$/='${RELEASE_TAG#refs/tags/konfigkoll-v}'/' "aur/PKGBUILD"
+ # sed -i '/^pkgrel/s/=.*$/=1/' "aur/PKGBUILD"
+ # env:
+ # RELEASE_TAG: ${{ github.ref }}
+ # - name: Publish AUR package
+ # uses: KSXGitHub/github-actions-deploy-aur@v2.7.2
+ # with:
+ # pkgname: konfigkoll
+ # pkgbuild: aur/PKGBUILD
+ # updpkgsums: true
+ # commit_username: ${{ secrets.AUR_USERNAME }}
+ # commit_email: ${{ secrets.AUR_EMAIL }}
+ # ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+ # commit_message: New upstream release (automatic update from GitHub Actions)
diff --git a/.github/workflows/site-release.yml b/.github/workflows/site-release.yml
new file mode 100644
index 00000000..040555e5
--- /dev/null
+++ b/.github/workflows/site-release.yml
@@ -0,0 +1,56 @@
+name: Website
+
+on:
+ push:
+ tags:
+ - v[0-9]+.*
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # To push a branch
+ pages: write # To push to a GitHub Pages site
+ id-token: write # To update the deployment status
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Install latest mdbook
+ run: |
+ tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
+ url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
+ mkdir mdbook
+ curl -sSL $url | tar -xz --directory=./mdbook
+ echo `pwd`/mdbook >> $GITHUB_PATH
+ - run: mkdir -p target/site/
+ - name: Build Book
+ run: |
+ (cd doc && mdbook build -d ../target/site/book)
+ - name: Add static landing page
+ run: cp -r site/* target/site/
+
+ - name: Install Rust
+ run: rustup update stable && rustup default stable
+ - name: Cache builds
+ uses: Swatinem/rust-cache@v2.7.3
+ with:
+ key: website
+ - name: Build Rune API docs
+ run: cargo run --bin konfigkoll-rune -- doc --output target/site/api
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ # Upload book directory
+ path: 'target/site'
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index dab02fec..065e70c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,12 @@
.gdb_history
.idea
+*.code-workspace
/.vscode
+/patches
/target
+/test_config
flamegraph.svg
heaptrack.*
memory-profiling_*
perf.data
-perf.data.old
+perf.data.old
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 92c31f51..c35741b6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -39,6 +39,18 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "aliasable"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
+
[[package]]
name = "anstream"
version = "0.6.14"
@@ -124,6 +136,18 @@ dependencies = [
"rustc-demangle",
]
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "base64-simd"
version = "0.8.0"
@@ -134,6 +158,15 @@ dependencies = [
"vsimd",
]
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -165,10 +198,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706"
dependencies = [
"memchr",
- "regex-automata",
+ "regex-automata 0.4.7",
"serde",
]
+[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
+
[[package]]
name = "bzip2"
version = "0.4.4"
@@ -190,6 +241,29 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "cached"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4d73155ae6b28cf5de4cfc29aeb02b8a1c6dab883cb015d15cd514e42766846"
+dependencies = [
+ "ahash",
+ "directories",
+ "hashbrown 0.14.5",
+ "once_cell",
+ "rmp-serde",
+ "serde",
+ "sled",
+ "thiserror",
+ "web-time",
+]
+
+[[package]]
+name = "camino"
+version = "1.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239"
+
[[package]]
name = "castaway"
version = "0.2.3"
@@ -201,15 +275,20 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.0.106"
+version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "066fce287b1d4eafef758e89e09d724a24808a9196fe9756b8ca90e86d0719a2"
+checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f"
dependencies = [
"jobserver",
"libc",
- "once_cell",
]
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -259,7 +338,7 @@ version = "4.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
dependencies = [
- "heck",
+ "heck 0.5.0",
"proc-macro2",
"quote",
"syn",
@@ -281,6 +360,12 @@ dependencies = [
"roff",
]
+[[package]]
+name = "clru"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59"
+
[[package]]
name = "cmake"
version = "0.1.50"
@@ -290,27 +375,61 @@ dependencies = [
"cc",
]
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
[[package]]
name = "colorchoice"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "compact_str"
-version = "0.7.1"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644"
dependencies = [
"castaway",
"cfg-if",
"itoa",
+ "rustversion",
"ryu",
"serde",
"smallvec",
"static_assertions",
]
+[[package]]
+name = "console"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb"
+dependencies = [
+ "encode_unicode",
+ "lazy_static",
+ "libc",
+ "unicode-width",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "const-random"
version = "0.1.18"
@@ -331,6 +450,31 @@ dependencies = [
"tiny-keccak",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.4.2"
@@ -340,6 +484,12 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "critical-section"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7059fff8937831a9ae6f0fe4d658ffabf58f2ca96aa9dec1c889f936f705f216"
+
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
@@ -383,9 +533,9 @@ dependencies = [
[[package]]
name = "darling"
-version = "0.20.9"
+version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1"
+checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core",
"darling_macro",
@@ -393,9 +543,9 @@ dependencies = [
[[package]]
name = "darling_core"
-version = "0.20.9"
+version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120"
+checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
@@ -407,9 +557,9 @@ dependencies = [
[[package]]
name = "darling_macro"
-version = "0.20.9"
+version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178"
+checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
@@ -426,7 +576,7 @@ dependencies = [
"hashbrown 0.14.5",
"lock_api",
"once_cell",
- "parking_lot_core",
+ "parking_lot_core 0.9.10",
]
[[package]]
@@ -440,10 +590,19 @@ dependencies = [
"hashbrown 0.14.5",
"lock_api",
"once_cell",
- "parking_lot_core",
+ "parking_lot_core 0.9.10",
"rayon",
]
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
[[package]]
name = "derive_builder"
version = "0.20.0"
@@ -491,6 +650,15 @@ dependencies = [
"crypto-common",
]
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
[[package]]
name = "dirs"
version = "5.0.1"
@@ -521,12 +689,30 @@ dependencies = [
"const-random",
]
+[[package]]
+name = "duct"
+version = "0.13.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4ab5718d1224b63252cd0c6f74f6480f9ffeb117438a2e0f5cf6d9a4798929c"
+dependencies = [
+ "libc",
+ "once_cell",
+ "os_pipe",
+ "shared_child",
+]
+
[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
+[[package]]
+name = "encode_unicode"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
+
[[package]]
name = "env_filter"
version = "0.1.0"
@@ -550,6 +736,12 @@ dependencies = [
"log",
]
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
[[package]]
name = "errno"
version = "0.3.9"
@@ -566,6 +758,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"
+[[package]]
+name = "fastrand"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
+
[[package]]
name = "filetime"
version = "0.2.23"
@@ -604,6 +802,58 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -614,6 +864,15 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getopts"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
+dependencies = [
+ "unicode-width",
+]
+
[[package]]
name = "getrandom"
version = "0.2.15"
@@ -646,8 +905,22 @@ dependencies = [
"aho-corasick",
"bstr",
"log",
- "regex-automata",
- "regex-syntax",
+ "regex-automata 0.4.7",
+ "regex-syntax 0.8.4",
+]
+
+[[package]]
+name = "handlebars"
+version = "4.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faa67bab9ff362228eb3d00bd024a4965d8231bbb7921167f0cfa66c6626b225"
+dependencies = [
+ "log",
+ "pest",
+ "pest_derive",
+ "serde",
+ "serde_json",
+ "thiserror",
]
[[package]]
@@ -664,6 +937,16 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+ "allocator-api2",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
@@ -677,6 +960,15 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+[[package]]
+name = "home"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "humantime"
version = "2.1.0"
@@ -689,6 +981,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
[[package]]
name = "ignore"
version = "0.4.22"
@@ -699,12 +1001,22 @@ dependencies = [
"globset",
"log",
"memchr",
- "regex-automata",
+ "regex-automata 0.4.7",
"same-file",
"walkdir",
"winapi-util",
]
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "indoc"
version = "2.0.5"
@@ -712,84 +1024,271 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
[[package]]
-name = "is_terminal_polyfill"
-version = "1.70.0"
+name = "instant"
+version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
[[package]]
-name = "itoa"
-version = "1.0.11"
+name = "is_terminal_polyfill"
+version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]]
-name = "jobserver"
-version = "0.1.31"
+name = "itertools"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
- "libc",
+ "either",
]
[[package]]
-name = "lasso"
-version = "0.7.2"
+name = "itertools"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4644821e1c3d7a560fe13d842d13f587c07348a1a05d3a797152d41c90c56df2"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
- "ahash",
- "dashmap 5.5.3",
- "hashbrown 0.13.2",
+ "either",
]
[[package]]
-name = "libc"
-version = "0.2.155"
+name = "itoa"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
-name = "libmimalloc-sys"
-version = "0.1.39"
+name = "jni"
+version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
- "cc",
- "libc",
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror",
+ "walkdir",
+ "windows-sys 0.45.0",
]
[[package]]
-name = "libredox"
-version = "0.1.3"
+name = "jni-sys"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
-dependencies = [
- "bitflags 2.6.0",
- "libc",
-]
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
-name = "libz-ng-sys"
-version = "1.1.15"
+name = "jobserver"
+version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6409efc61b12687963e602df8ecf70e8ddacf95bc6576bcf16e3ac6328083c5"
+checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e"
dependencies = [
- "cmake",
"libc",
]
[[package]]
-name = "linux-raw-sys"
-version = "0.4.14"
+name = "js-sys"
+version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+dependencies = [
+ "wasm-bindgen",
+]
[[package]]
-name = "lock_api"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+name = "konfigkoll"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "camino",
+ "clap",
+ "compact_str",
+ "directories",
+ "either",
+ "itertools 0.13.0",
+ "konfigkoll_core",
+ "konfigkoll_script",
+ "konfigkoll_types",
+ "mimalloc",
+ "ouroboros",
+ "paketkoll_cache",
+ "paketkoll_core",
+ "paketkoll_types",
+ "rayon",
+ "rune",
+ "tokio",
+ "tracing",
+ "tracing-log",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "konfigkoll_core"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "camino",
+ "clru",
+ "compact_str",
+ "console",
+ "duct",
+ "either",
+ "indoc",
+ "itertools 0.13.0",
+ "konfigkoll_types",
+ "libc",
+ "nix",
+ "paketkoll_types",
+ "paketkoll_utils",
+ "parking_lot 0.12.3",
+ "pretty_assertions",
+ "rayon",
+ "regex",
+ "smallvec",
+ "strum",
+ "tracing",
+]
+
+[[package]]
+name = "konfigkoll_hwinfo"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "indoc",
+ "itertools 0.13.0",
+ "pretty_assertions",
+ "rune",
+ "winnow 0.6.15",
+]
+
+[[package]]
+name = "konfigkoll_script"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "camino",
+ "compact_str",
+ "glob",
+ "indoc",
+ "itertools 0.13.0",
+ "konfigkoll_core",
+ "konfigkoll_hwinfo",
+ "konfigkoll_types",
+ "nix",
+ "paketkoll_core",
+ "paketkoll_types",
+ "paketkoll_utils",
+ "parking_lot 0.12.3",
+ "pretty_assertions",
+ "regex",
+ "rune",
+ "rune-modules",
+ "rust-ini",
+ "smallvec",
+ "sysinfo",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "winnow 0.6.15",
+]
+
+[[package]]
+name = "konfigkoll_types"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "bitflags 2.6.0",
+ "camino",
+ "compact_str",
+ "either",
+ "paketkoll_types",
+ "paketkoll_utils",
+ "strum",
+]
+
+[[package]]
+name = "lasso"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4644821e1c3d7a560fe13d842d13f587c07348a1a05d3a797152d41c90c56df2"
+dependencies = [
+ "ahash",
+ "dashmap 5.5.3",
+ "hashbrown 0.13.2",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.155"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
+
+[[package]]
+name = "libmimalloc-sys"
+version = "0.1.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags 2.6.0",
+ "libc",
+]
+
+[[package]]
+name = "libz-ng-sys"
+version = "1.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6409efc61b12687963e602df8ecf70e8ddacf95bc6576bcf16e3ac6328083c5"
+dependencies = [
+ "cmake",
+ "libc",
+]
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
+
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
"autocfg",
"scopeguard",
@@ -801,6 +1300,19 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
+[[package]]
+name = "lsp-types"
+version = "0.94.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1"
+dependencies = [
+ "bitflags 1.3.2",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "url",
+]
+
[[package]]
name = "lzma-sys"
version = "0.1.20"
@@ -812,6 +1324,24 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "matchers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+dependencies = [
+ "regex-automata 0.1.10",
+]
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -846,6 +1376,17 @@ dependencies = [
"adler",
]
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "mtree2"
version = "0.6.1"
@@ -853,8 +1394,54 @@ dependencies = [
"bitflags 2.6.0",
"faster-hex",
"memchr",
+ "smallvec",
+]
+
+[[package]]
+name = "musli"
+version = "0.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c21124dd24833900879114414b877f2136f4b7b7a3b49756ecc5c36eca332bb"
+dependencies = [
+ "musli-macros",
+]
+
+[[package]]
+name = "musli-common"
+version = "0.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "178446623aa62978aa0f894b2081bc11ea77c2119ccfe35be428ab9ddb495dfc"
+dependencies = [
+ "musli",
+]
+
+[[package]]
+name = "musli-macros"
+version = "0.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1ab0e4ac2721bc4fa3528a6a2640c1c30c36c820f8c85159252fbf6c2fac24"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "musli-storage"
+version = "0.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2fc1f80b166f611c462e1344220e9b3a9ad37c885e43039d5d2e6887445937c"
+dependencies = [
+ "musli",
+ "musli-common",
]
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
[[package]]
name = "nix"
version = "0.29.0"
@@ -867,6 +1454,104 @@ dependencies = [
"libc",
]
+[[package]]
+name = "ntapi"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "num_cpus"
version = "1.16.0"
@@ -877,6 +1562,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
[[package]]
name = "object"
version = "0.36.1"
@@ -891,6 +1585,32 @@ name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
+
+[[package]]
+name = "onig"
+version = "6.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
[[package]]
name = "option-ext"
@@ -918,12 +1638,53 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29d73ba8daf8fac13b0501d1abeddcfe21ba7401ada61a819144b6c2a4f32209"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "ouroboros"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "944fa20996a25aded6b4795c6d63f10014a7a83f8be9828a11860b08c5fc4a67"
+dependencies = [
+ "aliasable",
+ "ouroboros_macro",
+ "static_assertions",
+]
+
+[[package]]
+name = "ouroboros_macro"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39b0deead1528fd0e5947a8546a9642a9777c25f6e1e26f34c97b204bbb465bd"
+dependencies = [
+ "heck 0.4.1",
+ "itertools 0.12.1",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "outref"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a"
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
[[package]]
name = "paketkoll"
version = "0.2.3"
@@ -931,20 +1692,32 @@ dependencies = [
"ahash",
"anyhow",
"clap",
- "clap_complete",
- "clap_mangen",
"compact_str",
"env_logger",
"log",
"mimalloc",
"os_info",
"paketkoll_core",
+ "paketkoll_types",
"proc-exit",
"rayon",
"serde",
"serde_json",
]
+[[package]]
+name = "paketkoll_cache"
+version = "0.1.0"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "cached",
+ "compact_str",
+ "dashmap 6.0.1",
+ "paketkoll_types",
+ "tracing",
+]
+
[[package]]
name = "paketkoll_core"
version = "0.4.1"
@@ -994,15 +1767,19 @@ dependencies = [
name = "paketkoll_types"
version = "0.1.0"
dependencies = [
+ "ahash",
"anyhow",
"bitflags 2.6.0",
"compact_str",
+ "dashmap 6.0.1",
"derive_builder",
"faster-hex",
"lasso",
"nix",
"serde",
"smallvec",
+ "strum",
+ "thiserror",
]
[[package]]
@@ -1014,6 +1791,41 @@ dependencies = [
"ring",
]
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core 0.8.6",
+]
+
+[[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 0.9.10",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
+dependencies = [
+ "cfg-if",
+ "instant",
+ "libc",
+ "redox_syscall 0.2.16",
+ "smallvec",
+ "winapi",
+]
+
[[package]]
name = "parking_lot_core"
version = "0.9.10"
@@ -1022,11 +1834,68 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall 0.5.2",
+ "redox_syscall 0.5.3",
"smallvec",
"windows-targets 0.52.6",
]
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pest"
+version = "2.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95"
+dependencies = [
+ "memchr",
+ "thiserror",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f"
+dependencies = [
+ "once_cell",
+ "pest",
+ "sha2",
+]
+
[[package]]
name = "phf"
version = "0.11.2"
@@ -1069,12 +1938,75 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "pin-project"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
+[[package]]
+name = "plist"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
[[package]]
name = "pretty_assertions"
version = "1.4.0"
@@ -1082,7 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
dependencies = [
"diff",
- "yansi",
+ "yansi 0.5.1",
]
[[package]]
@@ -1100,6 +2032,40 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+ "yansi 1.0.1",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
+dependencies = [
+ "bitflags 2.6.0",
+ "getopts",
+ "memchr",
+ "unicase",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.36"
@@ -1115,6 +2081,18 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
"rand_core",
]
@@ -1123,6 +2101,15 @@ name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
[[package]]
name = "rayon"
@@ -1144,6 +2131,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -1155,9 +2151,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd"
+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
dependencies = [
"bitflags 2.6.0",
]
@@ -1181,8 +2177,17 @@ checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick",
"memchr",
- "regex-automata",
- "regex-syntax",
+ "regex-automata 0.4.7",
+ "regex-syntax 0.8.4",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+dependencies = [
+ "regex-syntax 0.6.29",
]
[[package]]
@@ -1193,35 +2198,224 @@ checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
- "regex-syntax",
+ "regex-syntax 0.8.4",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.6.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "relative-path"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "spin",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rmp"
+version = "0.8.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
+dependencies = [
+ "byteorder",
+ "num-traits",
+ "paste",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
+dependencies = [
+ "byteorder",
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "roff"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316"
+
+[[package]]
+name = "ropey"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5"
+dependencies = [
+ "smallvec",
+ "str_indices",
+]
+
+[[package]]
+name = "rune"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d21925ac4f8974395d0d9e43f96a34c778e71ed86fe96d0313b2211102537234"
+dependencies = [
+ "anyhow",
+ "base64 0.21.7",
+ "bincode",
+ "clap",
+ "codespan-reporting",
+ "futures-core",
+ "futures-util",
+ "handlebars",
+ "itoa",
+ "linked-hash-map",
+ "lsp-types",
+ "musli",
+ "musli-storage",
+ "num",
+ "once_cell",
+ "parking_lot 0.12.3",
+ "percent-encoding",
+ "pin-project",
+ "pulldown-cmark",
+ "rand",
+ "relative-path",
+ "ropey",
+ "rune-alloc",
+ "rune-core",
+ "rune-macros",
+ "rust-embed",
+ "ryu",
+ "semver",
+ "serde",
+ "serde-hashkey",
+ "serde_json",
+ "sha2",
+ "similar",
+ "syntect",
+ "tokio",
+ "toml",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "webbrowser",
+]
+
+[[package]]
+name = "rune-alloc"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e85c26e19f7efb91c6e19afc68b008f04685fdb2852e96ce8fbd3cf4a0b4e76c"
+dependencies = [
+ "ahash",
+ "pin-project",
+ "rune-alloc-macros",
+ "serde",
+]
+
+[[package]]
+name = "rune-alloc-macros"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "810588952a8710959d35ad17c933804d60f96c3792f216277cda68c1a9887120"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "rune-core"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d30fa78b6cb15d1560bb4cb18f4b99a9097b08ade3a5fc23e5ae7311f97c537b"
+dependencies = [
+ "byteorder",
+ "musli",
+ "rune-alloc",
+ "serde",
+ "twox-hash",
+]
+
+[[package]]
+name = "rune-macros"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1b91e53bae3804e4d72e2b04fa5d5108bd93e880ca597c0cae0fb0a662fe198"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rune-core",
+ "syn",
+]
+
+[[package]]
+name = "rune-modules"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2be1152db68e40ef9b76c1eb2119509da9e11fc58756b5d6894c1d79da744f0f"
+dependencies = [
+ "rune",
+ "serde_json",
+ "tokio",
+ "toml",
]
[[package]]
-name = "regex-syntax"
-version = "0.8.4"
+name = "rust-embed"
+version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
+dependencies = [
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
[[package]]
-name = "ring"
-version = "0.17.8"
+name = "rust-embed-impl"
+version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
dependencies = [
- "cc",
- "cfg-if",
- "getrandom",
- "libc",
- "spin",
- "untrusted",
- "windows-sys 0.52.0",
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "syn",
+ "walkdir",
]
[[package]]
-name = "roff"
-version = "0.2.1"
+name = "rust-embed-utils"
+version = "7.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316"
+checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
+dependencies = [
+ "sha2",
+ "walkdir",
+]
[[package]]
name = "rust-ini"
@@ -1280,6 +2474,15 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "semver"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde"
version = "1.0.204"
@@ -1289,6 +2492,15 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-hashkey"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13a90d3c31ebd0b83e38600c8117083ec4c4e1a7a0cab364e79e19706ade04e"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_derive"
version = "1.0.204"
@@ -1311,12 +2523,96 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_repr"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shared_child"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "similar"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e"
+dependencies = [
+ "bstr",
+]
+
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+[[package]]
+name = "sled"
+version = "0.34.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
+dependencies = [
+ "crc32fast",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "fs2",
+ "fxhash",
+ "libc",
+ "log",
+ "parking_lot 0.11.2",
+]
+
[[package]]
name = "smallvec"
version = "1.13.2"
@@ -1341,89 +2637,303 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "str_indices"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c"
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syntect"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
+dependencies = [
+ "bincode",
+ "bitflags 1.3.2",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax 0.8.4",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3"
+dependencies = [
+ "cfg-if",
+ "core-foundation-sys",
+ "libc",
+ "ntapi",
+ "once_cell",
+ "rayon",
+ "windows",
+]
+
+[[package]]
+name = "systemd_tmpfiles"
+version = "0.1.1"
+dependencies = [
+ "base64-simd",
+ "bitflags 2.6.0",
+ "compact_str",
+ "dirs",
+ "indoc",
+ "libc",
+ "memchr",
+ "nix",
+ "pretty_assertions",
+ "smallvec",
+ "strum",
+ "thiserror",
+ "winnow 0.6.15",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "rustix",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "time"
+version = "0.3.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
-name = "strum"
-version = "0.26.3"
+name = "tokio"
+version = "1.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df"
dependencies = [
- "strum_macros",
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot 0.12.3",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "tokio-macros",
+ "windows-sys 0.48.0",
]
[[package]]
-name = "strum_macros"
-version = "0.26.4"
+name = "tokio-macros"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
- "heck",
"proc-macro2",
"quote",
- "rustversion",
"syn",
]
[[package]]
-name = "syn"
-version = "2.0.69"
+name = "toml"
+version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "201fcda3845c23e8212cd466bfebf0bd20694490fc0356ae8e428e0824a915a6"
+checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
]
[[package]]
-name = "systemd_tmpfiles"
-version = "0.1.1"
+name = "toml_datetime"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
dependencies = [
- "base64-simd",
- "bitflags 2.6.0",
- "compact_str",
- "dirs",
- "indoc",
- "libc",
- "memchr",
- "nix",
- "pretty_assertions",
- "smallvec",
- "strum",
- "thiserror",
- "winnow",
+ "serde",
]
[[package]]
-name = "tar"
-version = "0.4.41"
+name = "toml_edit"
+version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb797dad5fb5b76fcf519e702f4a589483b5ef06567f160c392832c1f5e44909"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
- "filetime",
- "libc",
- "xattr",
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow 0.5.40",
]
[[package]]
-name = "thiserror"
-version = "1.0.63"
+name = "tracing"
+version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
- "thiserror-impl",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
]
[[package]]
-name = "thiserror-impl"
-version = "1.0.63"
+name = "tracing-attributes"
+version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
+checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
@@ -1431,12 +2941,43 @@ dependencies = [
]
[[package]]
-name = "tiny-keccak"
-version = "2.0.2"
+name = "tracing-core"
+version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [
- "crunchy",
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "parking_lot 0.12.3",
+ "regex",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
]
[[package]]
@@ -1445,30 +2986,94 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
+[[package]]
+name = "twox-hash"
+version = "1.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
+dependencies = [
+ "cfg-if",
+ "static_assertions",
+]
+
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+[[package]]
+name = "ucd-trie"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
+
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d"
+
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "url"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "valuable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+
[[package]]
name = "version_check"
version = "0.9.4"
@@ -1497,6 +3102,113 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+
+[[package]]
+name = "web-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b"
+dependencies = [
+ "core-foundation",
+ "home",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc",
+ "raw-window-handle",
+ "url",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
[[package]]
name = "winapi-util"
version = "0.1.8"
@@ -1506,6 +3218,40 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
+dependencies = [
+ "windows-core",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -1524,6 +3270,21 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -1555,6 +3316,12 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -1567,6 +3334,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -1579,6 +3352,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -1597,6 +3376,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -1609,6 +3394,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -1621,6 +3412,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -1633,6 +3430,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -1647,9 +3450,18 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
-version = "0.6.14"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "374ec40a2d767a3c1b4972d9475ecd557356637be906f2cb3f7fe17a6eb5e22f"
+checksum = "557404e450152cd6795bb558bca69e43c585055f4606e3bcae5894fc6dac9ba0"
dependencies = [
"memchr",
]
@@ -1665,6 +3477,21 @@ dependencies = [
"rustix",
]
+[[package]]
+name = "xtask"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "camino",
+ "clap",
+ "clap_complete",
+ "clap_mangen",
+ "env_logger",
+ "konfigkoll",
+ "log",
+ "paketkoll",
+]
+
[[package]]
name = "xz2"
version = "0.1.7"
@@ -1674,12 +3501,27 @@ dependencies = [
"lzma-sys",
]
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
[[package]]
name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
[[package]]
name = "zerocopy"
version = "0.7.35"
diff --git a/Cargo.toml b/Cargo.toml
index 0f2afa5f..1d77eb16 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,14 +10,20 @@ base64-simd = "0.8.0"
bitflags = "2.6.0"
bstr = "1.9.1"
bzip2 = "0.4.4"
+cached = { version = "0.53.1", default-features = false }
+camino = "1.1.7"
cfg-if = "1.0.0"
clap = "4.5.9"
clap_complete = "4.5.8"
clap_mangen = "0.2.22"
-compact_str = "0.7.1"
+clru = "0.6.2"
+compact_str = "0.8.0"
+console = "0.15.8"
dashmap = "6.0.1"
derive_builder = "0.20.0"
+directories = "5.0.1"
dirs = "5.0.1"
+duct = "0.13.7"
either = "1.13.0"
env_logger = "0.11.3"
faster-hex = { version = "0.9.0", default-features = false }
@@ -28,6 +34,7 @@ flume = { version = "0.11.0", default-features = false }
glob = "0.3.1"
ignore = "0.4.22"
indoc = "2.0.5"
+itertools = "0.13.0"
lasso = "0.7.2"
libc = "0.2.155"
log = "0.4.22"
@@ -37,21 +44,35 @@ mimalloc = "0.1.43"
nix = { version = "0.29.0", default-features = false }
num_cpus = "1.16.0"
os_info = { version = "3.8.2", default-features = false }
+ouroboros = "0.18.4"
+parking_lot = "0.12.3"
phf = "0.11.2"
pretty_assertions = "1.4.0"
proc-exit = "2.0.1"
rayon = "1.10.0"
regex = "1.10.5"
ring = "0.17.8"
+rune = "0.13.4"
+rune-modules = "0.13.4"
rust-ini = "0.21.0"
scopeguard = "1.2.0"
serde = "1.0.204"
serde_json = "1.0.120"
-smallvec = "1.13.2"
-strum = "0.26.3"
+smallvec = { version = "1.13.2", features = [
+ "const_generics",
+ "const_new",
+ "union",
+] }
+strum = { version = "0.26.3", features = ["derive"] }
+sysinfo = "0.30.13"
tar = "0.4.41"
+tempfile = "3.10.1"
thiserror = "1.0.63"
-winnow = "0.6.14"
+tokio = "1.38.1"
+tracing = "0.1.40"
+tracing-log = "0.2.0"
+tracing-subscriber = "0.3.18"
+winnow = "0.6.15"
xz2 = "0.1.7"
zstd = "0.13.2"
@@ -63,6 +84,8 @@ doc_markdown = "warn"
needless_pass_by_value = "warn"
redundant_closure_for_method_calls = "warn"
semicolon_if_nothing_returned = "warn"
+undocumented_unsafe_blocks = "warn"
+unnecessary_safety_doc = "warn"
unwrap_used = "warn"
wildcard_imports = "warn"
@@ -72,15 +95,29 @@ split-debuginfo = "unpacked"
[profile.release]
codegen-units = 1
-lto = "fat"
+lto = "thin"
opt-level = 2
[profile.profiling]
debug = 2
inherits = "release"
+lto = false
-[profile.dev.package.ring]
-opt-level = 2
+[profile.dev.package]
+# Needed for reasonable performance
+flate2.opt-level = 2
+libz-ng-sys.opt-level = 2
+md-5.opt-level = 2
+proc-macro2.opt-level = 2
+quote.opt-level = 2
+ring.opt-level = 2
+rune-macros.opt-level = 2
+serde_derive.opt-level = 2
+syn.opt-level = 2
+zstd-safe.opt-level = 2
+zstd-sys.opt-level = 2
+zstd.opt-level = 2
-[profile.dev.package.md-5]
-opt-level = 2
+#[patch.crates-io]
+## Rune
+#rune = { path = "patches/rune/crates/rune" }
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..af7058c1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,53 @@
+# This makefile exists to allow for an install target, since it seems
+# cargo install is too basic to handle installing support files
+
+CARGO_FLAGS ?=
+DESTDIR ?=
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+DATADIR ?= $(PREFIX)/share
+BASHDIR ?= $(DATADIR)/bash-completion/completions
+ZSHDIR ?= $(DATADIR)/zsh/site-functions
+FISHDIR ?= $(DATADIR)/fish/vendor_completions.d
+MANDIR ?= $(DATADIR)/man/man1
+
+PROGS := target/release/paketkoll target/release/konfigkoll target/release/konfigkoll-rune target/release/xtask
+
+all: $(PROGS)
+
+target/release/paketkoll: build-cargo
+target/release/konfigkoll: build-cargo
+target/release/konfigkoll-rune: build-cargo
+target/release/xtask: build-cargo
+
+build-cargo:
+ # Let cargo figure out if a build is needed
+ cargo build --locked --release $(CARGO_FLAGS)
+
+test:
+ cargo test --locked --release $(CARGO_FLAGS)
+
+install: install-paketkoll install-konfigkoll
+
+install-paketkoll: target/release/paketkoll target/release/xtask install-dirs
+ install $< $(DESTDIR)$(BINDIR)
+ ./target/release/xtask man --output $(DESTDIR)$(MANDIR) paketkoll
+ ./target/release/xtask completions --output target/completions paketkoll
+ install -Dm644 target/completions/paketkoll.bash $(DESTDIR)$(BASHDIR)/paketkoll
+ install -Dm644 target/completions/paketkoll.fish $(DESTDIR)$(FISHDIR)/paketkoll.fish
+ install -Dm644 target/completions/_paketkoll $(DESTDIR)$(ZSHDIR)/_paketkoll
+
+
+install-konfigkoll: target/release/konfigkoll target/release/konfigkoll-rune target/release/xtask install-dirs
+ install $< $(DESTDIR)$(BINDIR)
+ install target/release/konfigkoll-rune $(DESTDIR)$(BINDIR)
+ ./target/release/xtask man --output $(DESTDIR)$(MANDIR) konfigkoll
+ ./target/release/xtask completions --output target/completions konfigkoll
+ install -Dm644 target/completions/konfigkoll.bash $(DESTDIR)$(BASHDIR)/konfigkoll
+ install -Dm644 target/completions/konfigkoll.fish $(DESTDIR)$(FISHDIR)/konfigkoll.fish
+ install -Dm644 target/completions/_konfigkoll $(DESTDIR)$(ZSHDIR)/_konfigkoll
+
+install-dirs:
+ install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(BASHDIR) $(DESTDIR)$(ZSHDIR) $(DESTDIR)$(FISHDIR) $(DESTDIR)$(MANDIR)
+
+.PHONY: all build-cargo test install install-paketkoll install-konfigkoll install-dirs $(PROGS)
diff --git a/README.md b/README.md
index 25ac6b9c..e7a636e8 100644
--- a/README.md
+++ b/README.md
@@ -1,180 +1,24 @@
-# Paketkoll
-
-[ [lib.rs] ] [ [crates.io] ] [ [AUR] ]
-
-This is a Rust replacement for `debsums` (on Debian/Ubuntu/...) and `paccheck`
-(on Arch Linux and derivatives). It is much faster than those thanks to using
-all your CPU cores in parallel. (It is also much much faster than `pacman -Qkk`
-which is much slower than `paccheck` even.)
-
-What it does is compare installed files to what the package manager installed and
-report any discrepancies.
-
-* On Arch Linux it will report changed mode, owner, group, mtimes, symlink target,
- file content (sha256) or missing files.
-* On Debian it will only report if file content differs for regular files. That
- is the only information available on Debian unfortunately (the md5sum).
-
-Additional features:
-
-* There is a flag to include or not include "config files" (those marked as such
- by the package manager, which is not all files in `/etc` as one might think).
-* On Arch Linux you can pass `--trust-mtime` to not check the contents of files
- where the mtime matches. This makes the check ultra-fast.
-* Doesn't depend on any distro specific libraries for interacting with the package
- database. We do our own parsing. This makes it possible to be way faster
- (parallelism!) and also to make a cross platform binary that will run on either
- distro without any dependencies apart from libc.
-* You can also use this to find unmanaged files (not installed by the package
- manager) using `paketkoll check-unexpected`, though some work is required,
- since there are many legitimately unmanaged files. You may need to find a set
- of `--ignore` flags suitable for your system. Only some simple basics ignores
- are built in (`/proc`, `/sys`, `/home`, etc).
-
-Caveats:
-
-* This is not a drop-in replacement for either debsums nor paccheck, since
- command line flags and output format differs. Additionally, debsums have some
- extra features that this doesn't, such as filtering out files removed by localepurge.
-* This uses much more memory than `paccheck` (3x). This is largely unavoidable due
- to memory-speed tradeoffs, though there is room for *some* improvements still.
-* paketkoll will not report quite the same errors as `paccheck`. For example, if
- it finds that the size differs, it will not bother computing the checksums,
- since they can never match.
-
-## Benchmarks
-
-Note: CPU time is actually comparable to the original tools (slightly better in
-general). But due to parallelism the wall time is *way* better, especially
-without `--trust-mtime` (where the runtime is quite small to begin with).
-
-* All of the runs were performed on warm disk cache.
-* Distro-installed versions of paccheck and debsums were used.
-* Musl builds built using cross was used across the board for best portability.
-* The same build flags as used for binary releases in this were used (opt level 2, fat LTO)
-
-### Arch Linux (x64-64 AMD desktop)
-
-* CPU: AMD Ryzen 5 5600X 6-Core Processor (6 cores, 12 threads)
-* RAM: 32 GB, 2 DIMMs DDR4, 3600 MHz
-* Disk: NVME Gen4 (WD Black SN850 1TB)
-* Kernel: 6.7.5-arch1-1
-* `pacman -Q | wc -l` indicates 2211 packages installed
-
-When only checking file properties and trusting mtime (these should be the most similar options):
-
-```console
-$ hyperfine -i -N --warmup 1 "paketkoll --trust-mtime check" "paccheck --file-properties --quiet"
-Benchmark 1: paketkoll --trust-mtime
- Time (mean ± σ): 249.4 ms ± 4.8 ms [User: 1194.5 ms, System: 1216.2 ms]
- Range (min … max): 242.1 ms … 259.7 ms 12 runs
-
-Benchmark 2: paccheck --file-properties --quiet
- Time (mean ± σ): 2.561 s ± 0.020 s [User: 1.504 s, System: 1.053 s]
- Range (min … max): 2.527 s … 2.598 s 10 runs
-
- Warning: Ignoring non-zero exit code.
-
-Summary
- paketkoll --trust-mtime ran
- 10.27 ± 0.21 times faster than paccheck --file-properties --quiet
-```
-
-The speedup isn't quite as impressive when checking the checksums also, but it is still large:
-
-```console
-$ hyperfine -i -N --warmup 1 "paketkoll check" "paccheck --sha256sum --quiet"
-Benchmark 1: paketkoll
- Time (mean ± σ): 9.986 s ± 1.329 s [User: 17.368 s, System: 19.087 s]
- Range (min … max): 8.196 s … 11.872 s 10 runs
-
-Benchmark 2: paccheck --sha256sum --quiet
- Time (mean ± σ): 68.976 s ± 0.339 s [User: 16.661 s, System: 17.816 s]
- Range (min … max): 68.413 s … 69.604 s 10 runs
-
- Warning: Ignoring non-zero exit code.
-
-Summary
- paketkoll ran
- 6.91 ± 0.92 times faster than paccheck --sha256sum --quiet
-```
-
-* Many and large packages installed
-* 6 cores, 12 thread means a decent speed up from multi-threading is possible.
-* I don't know what paccheck was doing such that it took 68 seconds but didn't use very much CPU. Presumably waiting for IO?
-
-### Debian (ARM64 Raspberry Pi)
-
-* Raspberry Pi 5 (8 GB RAM)
-* CPU: Cortex-A76 (4 cores, 4 threads)
-* Disk: USB boot from SATA SSD in USB 3.0 enclosure: Samsung SSD 850 PRO 512GB
-* Kernel: 6.1.0-rpi8-rpi-2712
-* `dpkg-query -l | grep ii | wc -l` indicates 749 packages installed
-
-```console
-$ hyperfine -i -N --warmup 1 "paketkoll check" "debsums -c"
-Benchmark 1: paketkoll
- Time (mean ± σ): 2.664 s ± 0.102 s [User: 3.937 s, System: 1.116 s]
- Range (min … max): 2.543 s … 2.813 s 10 runs
-
-Benchmark 2: debsums -c
- Time (mean ± σ): 8.893 s ± 0.222 s [User: 5.453 s, System: 1.350 s]
- Range (min … max): 8.637 s … 9.199 s 10 runs
-
- Warning: Ignoring non-zero exit code.
-
-Summary
- 'paketkoll' ran
- 3.34 ± 0.15 times faster than 'debsums -c'
-```
-
-* There aren't a ton of packages installed on this system (it is acting as a headless server). This means that neither command is terribly slow.
-* A Pi only has 4 cores also, which limits the maximum possible speedup.
-
-### Ubuntu 22.04 (x86-64 Intel laptop)
-
-* CPU: 12th Gen Intel(R) Core(TM) i9-12950HX (8 P-cores with 16 threads + 8 E-cores with 8 threads)
-* RAM: 64 GB, 2 DIMMs DDR4, 3600 MHz
-* Disk: NVME Gen4 (WD SN810 2 TB)
-* Kernel: 6.5.0-17-generic (HWE kernel)
-* `dpkg-query -l | grep ii | wc -l` indicates 4012 packages installed
-
-```console
-$ hyperfine -i -N --warmup 1 "paketkoll check" "debsums -c"
-Benchmark 1: paketkoll
- Time (mean ± σ): 5.341 s ± 0.174 s [User: 42.553 s, System: 33.049 s]
- Range (min … max): 5.082 s … 5.586 s 10 runs
-
-Benchmark 2: debsums -c
- Time (mean ± σ): 92.839 s ± 7.332 s [User: 47.664 s, System: 15.697 s]
- Range (min … max): 82.872 s … 103.710 s 10 runs
-
- Warning: Ignoring non-zero exit code.
-
-Summary
- paketkoll ran
- 17.38 ± 1.49 times faster than debsums -c
-```
-
-## Future improvements
-
-Most future improvements will happen in the [`paketkoll_core`](crates/paketkoll_core)
-crate, to make it suitable for another project idea I have (basically that project
-needs this as a library).
-
-I consider the program itself mostly feature complete. The main changes would be
-bug fixes and possibly supporting additional Linux distributions and package managers.
-
-## MSRV (Minimum Supported Rust Version) policy
-
-The MSRV may be bumped as needed. It is guaranteed that this program will at
-least build on the current stable Rust release. An MSRV change is not considered
-a breaking change and as such may change even in a patch version.
-
-## What does the name mean?
-
-paketkoll is Swedish for "package check", though the translation to English isn't perfect ("ha koll på" means "keep an eye on" for example).
-
-[crates.io]: https://crates.io/crates/paketkoll
-[lib.rs]: https://lib.rs/crates/paketkoll
-[AUR]: https://aur.archlinux.org/packages/paketkoll
+# Paketkoll and konfigkoll
+
+This repository is home to two projects:
+
+* Paketkoll:\
+ A Rust replacement for `debsums` (on Debian/Ubuntu/...) and `paccheck`
+ (on Arch Linux and derivatives). It is much faster than those thanks to using
+ all your CPU cores in parallel. (It is also much much faster than `pacman -Qkk`
+ which is much slower than `paccheck` even.)\
+ \
+ Additionally it has some other commands such as finding what package owns a file,
+ etc. This program is pretty much done. See
+ [the README for paketkoll](crates/paketkoll/README.md) for more information.
+* Konfigkoll:\
+ A personal system configuration manager. This is for "Oh no, I have too many
+ computers and want to sync my configuration files between them using git".
+ It differs from ansible and similar (designed for sysadmins). This is [chezmoi]
+ for the whole computer. It is heavily inspired by [aconfmgr], but supports more
+ than just Arch Linux (specifically Debian and derivatives as well).\
+ **This program is very much a work in progress.**\
+ See [the README for konfigkoll](crates/konfigkoll/README.md) for more information.
+
+[chezmoi]: https://github.com/twpayne/chezmoi
+[aconfmgr]: https://github.com/CyberShadow/aconfmgr
diff --git a/Rune.toml b/Rune.toml
new file mode 100644
index 00000000..e69de29b
diff --git a/about.hbs b/about.hbs
new file mode 100644
index 00000000..d2df889a
--- /dev/null
+++ b/about.hbs
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
Third Party Licenses
+
This page lists the licenses of the projects used in konfigkoll and paketkoll.
+
+
+ Overview of licenses:
+
+ {{#each overview}}
+ - {{name}} ({{count}})
+ {{/each}}
+
+
+ All license text:
+
+ {{#each licenses}}
+ -
+
{{name}}
+ Used by:
+
+ {{text}}
+
+ {{/each}}
+
+
+
+
+
diff --git a/about.toml b/about.toml
new file mode 100644
index 00000000..55be61c6
--- /dev/null
+++ b/about.toml
@@ -0,0 +1,23 @@
+accepted = [
+ "MPL-2.0",
+ "Apache-2.0",
+ "MIT",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "OpenSSL",
+ "CC0-1.0",
+ "Unicode-DFS-2016",
+]
+targets = [
+ "aarch64-unknown-linux-musl",
+ "armv7-unknown-linux-musleabihf",
+ "i686-unknown-linux-musl",
+ "riscv64gc-unknown-linux-gnu",
+ "x86_64-unknown-linux-musl",
+]
+ignore-dev-dependencies = true
+private.ignore = true
+workarounds = [
+ "ring",
+]
diff --git a/clippy.toml b/clippy.toml
index 154626ef..65c188a2 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -1 +1,2 @@
allow-unwrap-in-tests = true
+check-private-items = true
diff --git a/crates/konfigkoll/Cargo.toml b/crates/konfigkoll/Cargo.toml
new file mode 100644
index 00000000..3b56bf90
--- /dev/null
+++ b/crates/konfigkoll/Cargo.toml
@@ -0,0 +1,74 @@
+[package]
+categories = [
+ "command-line-utilities",
+ "filesystem",
+ "os::linux-apis",
+ "config",
+]
+description = "Konfigkoll is a configuration management tool for Arch Linux and Debian (and derivatives)"
+edition = "2021"
+keywords = ["apt", "arch-linux", "debian", "pacman", "config-management"]
+license = "MPL-2.0"
+name = "konfigkoll"
+repository = "https://github.com/VorpalBlade/paketkoll"
+rust-version = "1.79.0"
+version = "0.1.0"
+
+[[bin]]
+name = "konfigkoll"
+path = "src/main.rs"
+
+[[bin]]
+name = "konfigkoll-rune"
+path = "src/bin/rune.rs"
+
+[features]
+# Default features
+default = ["arch_linux", "debian", "vendored"]
+
+# Include the Arch Linux backend
+arch_linux = ["konfigkoll_script/arch_linux"]
+
+# Include support for the Debian backend
+debian = ["konfigkoll_script/debian"]
+
+# Vendor C/C++ dependencies instead of linking them dynamically
+vendored = ["paketkoll_core/vendored"]
+
+[dependencies]
+ahash.workspace = true
+anyhow = { workspace = true, features = ["backtrace"] }
+camino.workspace = true
+clap = { workspace = true, features = ["derive"] }
+compact_str.workspace = true
+directories.workspace = true
+either.workspace = true
+itertools.workspace = true
+konfigkoll_core = { version = "0.1.0", path = "../konfigkoll_core" }
+konfigkoll_script = { version = "0.1.0", path = "../konfigkoll_script" }
+konfigkoll_types = { version = "0.1.0", path = "../konfigkoll_types" }
+ouroboros.workspace = true
+paketkoll_cache = { version = "0.1.0", path = "../paketkoll_cache" }
+paketkoll_core = { version = "0.4.1", path = "../paketkoll_core" }
+paketkoll_types = { version = "0.1.0", path = "../paketkoll_types" }
+rayon.workspace = true
+rune = { workspace = true, features = ["cli"] }
+tokio = { workspace = true, features = [
+ "macros",
+ "parking_lot",
+ "process",
+ "rt",
+ "sync",
+] }
+tracing-log.workspace = true
+tracing-subscriber = { workspace = true, features = ["env-filter", "parking_lot"] }
+tracing.workspace = true
+
+[target.'cfg(target_env = "musl")'.dependencies]
+# The allocator on musl is attrociously slow, so we use a custom one.
+# Jemalloc doesn't work reliably on Aarch64 due to varying page size, so use
+# the slightly slower mimalloc instead.
+mimalloc.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/konfigkoll/README.md b/crates/konfigkoll/README.md
new file mode 100644
index 00000000..0d946868
--- /dev/null
+++ b/crates/konfigkoll/README.md
@@ -0,0 +1,56 @@
+# Konfigkoll
+
+[Documentation] [ [lib.rs] ] [ [crates.io] ] [ [AUR] ]
+
+Konfigkoll is a work in progress cross distro configuration manager. It aims to solve the problem
+"I have too many computers and want to keep the system configs in sync", rather than
+"I am a sysadmin and want to manage a fleet". As such it is a *personal* system configuration manager.
+
+The design of konfigkoll is heavily inspired by the excellent [Aconfmgr](https://github.com/CyberShadow/aconfmgr),
+but with a few key differences:
+
+* Aconfmgr is Arch Linux specific, konfigkoll aims to be cross distro
+ (currently Arch Linux + work in progress support for Debian & derivatives).
+* Aconfmgr is written in Bash, and is rather slow. Konfigkoll is written in Rust, and is much faster.\
+ As an example, applying my personal config with aconfmgr on my system takes about 30 seconds, while konfigkoll
+ takes about 2 seconds for the equivalent config. (This is assuming `--trust-mtime`, both are
+ significantly slowed down if checksums are verified for every file).
+* Aconfmgr uses bash as the configuration language, konfigkoll uses [Rune].
+
+Please see [the documentation](https://vorpalblade.github.io/paketkoll/book#konfigkoll) for more information.
+
+## Installed binaries
+
+This crate consists of two binaries:
+
+### konfigkoll
+
+This is the main binary you will be interacting with
+
+### konfigkoll-rune
+
+This is a helper binary for [konfigkoll] that provides Rune support (the embedded
+scripting language used by konfigkoll) functions such as:
+
+* Documentation generation
+* LSP language server
+* Formatting of rune files
+* Syntax checking
+* etc
+
+## MSRV (Minimum Supported Rust Version) policy
+
+The MSRV may be bumped as needed. It is guaranteed that this program will at
+least build on the current stable Rust release. An MSRV change is not considered
+a breaking change and as such may change even in a patch version.
+
+## What does the name mean?
+
+konfigkoll is a Swedish for "config check/tracking", though
+the translation to English isn't perfect ("ha koll på" means "keep an eye on"
+for example). Some nuance is lost in the translation!
+
+[Documentation]: https://vorpalblade.github.io/paketkoll/book
+[crates.io]: https://crates.io/crates/konfigkoll
+[lib.rs]: https://lib.rs/crates/konfigkoll
+[AUR]: https://aur.archlinux.org/packages/konfigkoll
diff --git a/crates/konfigkoll/data/template/_gitignore b/crates/konfigkoll/data/template/_gitignore
new file mode 100644
index 00000000..02865404
--- /dev/null
+++ b/crates/konfigkoll/data/template/_gitignore
@@ -0,0 +1,8 @@
+*.dpkg-*
+*.old
+*.pacnew
+*.pacorig
+*.pacsave
+*.ucf-*
+*~
+/unsorted.rn
diff --git a/crates/konfigkoll/data/template/main.rn b/crates/konfigkoll/data/template/main.rn
new file mode 100644
index 00000000..403cb257
--- /dev/null
+++ b/crates/konfigkoll/data/template/main.rn
@@ -0,0 +1,73 @@
+// This is the main script for konfigkoll
+
+/// System configuration
+///
+/// Parameters:
+/// - props: A persistent properties object that the script can use to store
+/// data between phases
+/// - settings: Settings for konfigkoll
+pub async fn phase_system_discovery(props, settings) {
+ let sysinfo = sysinfo::SysInfo::new();
+ let os_id = sysinfo.os_id();
+ println!("Configuring for host {} (distro: {})", sysinfo.host_name()?, os_id);
+
+ // We need to enable the backends that we want to use
+ match os_id {
+ "arch" => {
+ settings.enable_pkg_backend("pacman")?;
+ settings.set_file_backend("pacman")?
+ },
+ "debian" => {
+ settings.enable_pkg_backend("apt")?;
+ settings.set_file_backend("apt")?
+ },
+ "ubuntu" => {
+ settings.enable_pkg_backend("apt")?;
+ settings.set_file_backend("apt")?
+ },
+ _ => return Err("Unsupported OS")?,
+ }
+ // Also enable flatpak
+ settings.enable_pkg_backend("flatpak")?;
+
+ Ok(())
+}
+
+/// Ignored paths
+pub async fn phase_ignores(props, cmds) {
+ // Note! Some ignores are built in to konfigkoll, so you don't need to add them here:
+ // These are things like /dev, /proc, /sys, /home etc. See documentation for
+ // current list of built in ignores.
+
+ // Ignore some common paths
+ cmds.ignore_path("/var/cache")?;
+ cmds.ignore_path("/var/spool")?;
+ // It is generally best to ignore the state directories of package managers,
+ // as they are managed separately.
+ cmds.ignore_path("/var/lib/flatpak")?;
+ cmds.ignore_path("/var/lib/pacman")?;
+ cmds.ignore_path("/var/lib/apt")?;
+ cmds.ignore_path("/var/lib/dpkg")?;
+ // Add more paths to ignore here
+ Ok(())
+}
+
+/// Early package phase, this is for packages that is needed by the script
+/// itself (e.g. if we need to call out to a command from that package)
+pub async fn phase_script_dependencies(props, cmds) {
+ Ok(())
+}
+
+/// Main phase, this is where the bulk of your configration should go
+///
+/// It is recommended to use the "save" sub-command to create an initial
+/// `unsorted.rn` file that you can then copy the parts you want from into here.
+///
+/// A tip is to use `konfigkoll -p dry-run save` the first few times to not
+/// *actually* save all the files, this helps you figure out what ignores to add
+/// above in `phase_ignores()` without copying a ton of files. Once you are happy
+/// with the ignores, you can remove the `-p dry-run` part.
+pub async fn phase_main(props, cmds, package_managers) {
+
+ Ok(())
+}
\ No newline at end of file
diff --git a/crates/konfigkoll/data/template/unsorted.rn b/crates/konfigkoll/data/template/unsorted.rn
new file mode 100644
index 00000000..99ee0aae
--- /dev/null
+++ b/crates/konfigkoll/data/template/unsorted.rn
@@ -0,0 +1,9 @@
+//! This file will be overwritten when you use `konfigkoll save`
+//!
+//! It is recommended to use the "save" sub-command to create an initial
+//! `unsorted.rn` file that you can then copy the parts you want from into `main.rn`.
+//!
+//! A tip is to use `konfigkoll -p dry-run save` the first few times to not
+//! *actually* save all the files, this helps you figure out what ignores to add
+//! above in `phase_ignores()` without copying a ton of files. Once you are happy
+//! with the ignores, you can remove the `-p dry-run` part.
diff --git a/crates/konfigkoll/src/apply.rs b/crates/konfigkoll/src/apply.rs
new file mode 100644
index 00000000..1d4d561a
--- /dev/null
+++ b/crates/konfigkoll/src/apply.rs
@@ -0,0 +1,46 @@
+//! Code for applying the configuration to the system.
+
+use std::{collections::BTreeMap, sync::Arc};
+
+use either::Either;
+use konfigkoll::cli::Paranoia;
+use konfigkoll_core::apply::Applicator;
+use paketkoll_types::{
+ backend::{Backend, Files, PackageBackendMap, PackageMap},
+ intern::Interner,
+};
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn create_applicator(
+ confirmation: Paranoia,
+ force_dry_run: bool,
+ backend_map: &PackageBackendMap,
+ interner: &Arc,
+ package_maps: &BTreeMap>,
+ files_backend: &Arc,
+ diff_command: Vec,
+ pager_command: Vec,
+) -> Box {
+ // TODO: This is where privilege separation would happen (well, one of the locations)
+ let inner_applicator = if force_dry_run {
+ Either::Left(konfigkoll_core::apply::NoopApplicator::default())
+ } else {
+ Either::Right(konfigkoll_core::apply::InProcessApplicator::new(
+ backend_map.clone(),
+ interner,
+ package_maps,
+ files_backend,
+ ))
+ };
+ // Create applicator based on paranoia setting
+ let applicator: Box = match confirmation {
+ Paranoia::Yolo => Box::new(inner_applicator),
+ Paranoia::Ask => Box::new(konfigkoll_core::apply::InteractiveApplicator::new(
+ inner_applicator,
+ diff_command,
+ pager_command,
+ )),
+ Paranoia::DryRun => Box::new(konfigkoll_core::apply::NoopApplicator::default()),
+ };
+ applicator
+}
diff --git a/crates/konfigkoll/src/bin/rune.rs b/crates/konfigkoll/src/bin/rune.rs
new file mode 100644
index 00000000..c9b1331a
--- /dev/null
+++ b/crates/konfigkoll/src/bin/rune.rs
@@ -0,0 +1,22 @@
+//! This is a helper binary for konfigkoll that provides Rune support functions
+//! such as:
+//!
+//! * Documentation generation
+//! * LSP langauge server
+//! * Formatting of rune files
+//! * Syntax checking
+use konfigkoll_script::ScriptEngine;
+
+#[cfg(target_env = "musl")]
+use mimalloc::MiMalloc;
+
+#[cfg(target_env = "musl")]
+#[cfg_attr(target_env = "musl", global_allocator)]
+static GLOBAL: MiMalloc = MiMalloc;
+
+fn main() {
+ rune::cli::Entry::new()
+ .about(format_args!("konfigkoll rune cli"))
+ .context(&mut |_opts| ScriptEngine::create_context())
+ .run();
+}
diff --git a/crates/konfigkoll/src/cli.rs b/crates/konfigkoll/src/cli.rs
new file mode 100644
index 00000000..9ab23fa5
--- /dev/null
+++ b/crates/konfigkoll/src/cli.rs
@@ -0,0 +1,52 @@
+use camino::Utf8PathBuf;
+use clap::{Parser, Subcommand};
+
+#[derive(Debug, Parser)]
+#[command(version, about, long_about = None)]
+#[command(propagate_version = true)]
+#[clap(disable_help_subcommand = true)]
+pub struct Cli {
+ /// Path to config directory (if not the current directory)
+ #[arg(long, short = 'c')]
+ pub config_path: Option,
+ /// Trust mtime (don't check checksum if mtime matches (not supported on Debian))
+ #[arg(long)]
+ pub trust_mtime: bool,
+ /// Decribe how much to ask for confirmation
+ #[arg(long, short = 'p', default_value = "ask")]
+ pub confirmation: Paranoia,
+ /// For debugging: force a dry run applicator
+ #[arg(long, hide = true, default_value = "false")]
+ pub debug_force_dry_run: bool,
+ /// Operation to perform
+ #[command(subcommand)]
+ pub command: Commands,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum Commands {
+ /// Create a new template config directory
+ Init {},
+ /// Save config to unsorted.rn script (for you to merge into your config)
+ Save {},
+ /// Check package files and search for unexpected files
+ Apply {},
+ /// Check for syntax errors and other issues
+ Check {},
+ /// Diff a specific path
+ Diff {
+ /// Path to diff
+ path: Utf8PathBuf,
+ },
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, clap::ValueEnum)]
+pub enum Paranoia {
+ /// Don't ask, just do it
+ Yolo,
+ /// Ask for groups of changes
+ #[default]
+ Ask,
+ /// Dry run, don't do anything
+ DryRun,
+}
diff --git a/crates/konfigkoll/src/fs_scan.rs b/crates/konfigkoll/src/fs_scan.rs
new file mode 100644
index 00000000..f70f7ae4
--- /dev/null
+++ b/crates/konfigkoll/src/fs_scan.rs
@@ -0,0 +1,80 @@
+//! Scan the file system
+
+use std::sync::Arc;
+
+use anyhow::Context;
+use compact_str::CompactString;
+use konfigkoll_types::FsInstruction;
+use ouroboros::self_referencing;
+use paketkoll_core::config::{
+ CheckAllFilesConfiguration, CommonFileCheckConfiguration, ConfigFiles,
+};
+use paketkoll_core::file_ops::{
+ canonicalize_file_entries, create_path_map, mismatching_and_unexpected_files,
+};
+use paketkoll_types::backend::Files;
+use paketkoll_types::files::FileEntry;
+use paketkoll_types::files::PathMap;
+use paketkoll_types::intern::Interner;
+
+#[self_referencing]
+pub(crate) struct ScanResult {
+ pub files: Vec,
+ #[borrows(files)]
+ #[covariant]
+ pub path_map: PathMap<'this>,
+}
+
+#[tracing::instrument(skip_all)]
+pub(crate) fn scan_fs(
+ interner: &Arc,
+ backend: &Arc,
+ ignores: &[CompactString],
+ trust_mtime: bool,
+) -> anyhow::Result<(ScanResult, Vec)> {
+ tracing::debug!("Scanning filesystem");
+ let mut fs_instructions_sys = vec![];
+ let mut files = backend.files(interner).with_context(|| {
+ format!(
+ "Failed to collect information from backend {}",
+ backend.name()
+ )
+ })?;
+ if backend.may_need_canonicalization() {
+ tracing::debug!("Canonicalizing file entries");
+ canonicalize_file_entries(&mut files);
+ }
+ // Drop mutability
+ let files = files;
+
+ tracing::debug!("Building path map");
+ let scan_result = ScanResultBuilder {
+ files,
+ path_map_builder: |files| create_path_map(files.as_slice()),
+ }
+ .build();
+
+ tracing::debug!("Checking for unexpected files");
+ let common_config = CommonFileCheckConfiguration::builder()
+ .trust_mtime(trust_mtime)
+ .config_files(ConfigFiles::Include)
+ .build()?;
+ let unexpected_config = CheckAllFilesConfiguration::builder()
+ .canonicalize_paths(backend.may_need_canonicalization())
+ .ignored_paths(ignores.to_owned())
+ .build()?;
+
+ let issues = mismatching_and_unexpected_files(
+ scan_result.borrow_files(),
+ scan_result.borrow_path_map(),
+ &common_config,
+ &unexpected_config,
+ )?;
+
+ // Convert issues to an instruction stream
+ fs_instructions_sys
+ .extend(konfigkoll_core::conversion::convert_issues_to_fs_instructions(issues)?);
+ // Ensure instructions are sorted
+ fs_instructions_sys.sort();
+ Ok((scan_result, fs_instructions_sys))
+}
diff --git a/crates/konfigkoll/src/lib.rs b/crates/konfigkoll/src/lib.rs
new file mode 100644
index 00000000..1e227bf7
--- /dev/null
+++ b/crates/konfigkoll/src/lib.rs
@@ -0,0 +1,4 @@
+//! This is only a bin+lib for technical reasons. Do not use this as a library.
+
+#[doc(hidden)]
+pub mod cli;
diff --git a/crates/konfigkoll/src/main.rs b/crates/konfigkoll/src/main.rs
new file mode 100644
index 00000000..585f38c3
--- /dev/null
+++ b/crates/konfigkoll/src/main.rs
@@ -0,0 +1,385 @@
+use ahash::AHashSet;
+use anyhow::Context;
+use apply::create_applicator;
+use camino::Utf8Path;
+use camino::Utf8PathBuf;
+use clap::Parser;
+use compact_str::CompactString;
+use itertools::Itertools;
+use konfigkoll::cli::Cli;
+use konfigkoll::cli::Commands;
+use konfigkoll::cli::Paranoia;
+use konfigkoll_core::apply::apply_files;
+use konfigkoll_core::apply::apply_packages;
+use konfigkoll_core::diff::show_fs_instr_diff;
+use konfigkoll_core::state::DiffGoal;
+use konfigkoll_script::Phase;
+use paketkoll_cache::FilesCache;
+use paketkoll_core::backend::ConcreteBackend;
+use paketkoll_core::paketkoll_types::intern::Interner;
+use paketkoll_types::backend::Files;
+use paketkoll_types::backend::PackageBackendMap;
+use paketkoll_types::backend::Packages;
+use std::io::BufWriter;
+use std::io::Write;
+use std::sync::Arc;
+
+mod apply;
+mod fs_scan;
+mod pkgs;
+mod save;
+
+#[cfg(target_env = "musl")]
+use mimalloc::MiMalloc;
+
+#[cfg(target_env = "musl")]
+#[cfg_attr(target_env = "musl", global_allocator)]
+static GLOBAL: MiMalloc = MiMalloc;
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+ // Set up logging with tracing
+ let filter = tracing_subscriber::EnvFilter::builder()
+ .with_default_directive(tracing::level_filters::LevelFilter::INFO.into())
+ .from_env()?;
+ let subscriber = tracing_subscriber::fmt::Subscriber::builder()
+ .with_env_filter(filter)
+ .finish();
+ tracing::subscriber::set_global_default(subscriber)?;
+ // Compatibility for log crate
+ tracing_log::LogTracer::init()?;
+
+ let cli = Cli::parse();
+
+ let config_path = match cli.config_path {
+ Some(v) => v,
+ None => std::env::current_dir()?.try_into()?,
+ };
+
+ if let Commands::Init {} = cli.command {
+ init_directory(&config_path)?;
+ return Ok(());
+ }
+
+ let mut script_engine = konfigkoll_script::ScriptEngine::new_with_files(&config_path)?;
+
+ match cli.command {
+ Commands::Init {} | Commands::Save {} | Commands::Apply {} | Commands::Diff { .. } => (),
+ Commands::Check {} => {
+ println!("Scripts loaded successfully");
+ return Ok(());
+ }
+ }
+
+ // Script: Do system discovery and configuration
+ script_engine.run_phase(Phase::SystemDiscovery).await?;
+
+ let proj_dirs = directories::ProjectDirs::from("", "", "konfigkoll")
+ .context("Failed to get directories for disk cache")?;
+
+ // Create backends
+ tracing::info!("Creating backends");
+ let interner = Arc::new(Interner::new());
+ let file_backend_id = script_engine
+ .state()
+ .settings()
+ .file_backend()
+ .ok_or_else(|| anyhow::anyhow!("A file backend must be set"))?;
+ let pkg_backend_ids = script_engine
+ .state()
+ .settings()
+ .enabled_pkg_backends()
+ .collect_vec();
+ let backend_cfg = paketkoll_core::backend::BackendConfiguration::builder()
+ .build()
+ .context("Failed to build backend config")?;
+ let backends_pkg: Arc = Arc::new(
+ pkg_backend_ids
+ .iter()
+ .map(|b| {
+ let b: ConcreteBackend = (*b)
+ .try_into()
+ .context("Backend is not supported by current build")?;
+ let backend = b
+ .create_packages(&backend_cfg, &interner)
+ .with_context(|| format!("Failed to create backend {b}"))?;
+ let b: Arc = Arc::from(backend);
+ Ok(b)
+ })
+ .map(|b| b.map(|b| (b.as_backend_enum(), b)))
+ .collect::>()?,
+ );
+
+ let backend_files: Arc = {
+ let b: ConcreteBackend = file_backend_id
+ .try_into()
+ .context("Backend is not supported by current build")?;
+ let backend = b
+ .create_files(&backend_cfg, &interner)
+ .with_context(|| format!("Failed to create backend {b}"))?;
+ let backend = FilesCache::from_path(backend, proj_dirs.cache_dir())
+ .context("Failed to create disk cache")?;
+ Arc::new(backend)
+ };
+
+ // Load installed packages
+ tracing::info!("Starting package loading background job");
+ let package_loader = {
+ let interner = interner.clone();
+ let backends_pkg = backends_pkg.clone();
+ tokio::task::spawn_blocking(move || pkgs::load_packages(&interner, &backends_pkg))
+ };
+ // Script: Get FS ignores
+ script_engine.run_phase(Phase::Ignores).await?;
+
+ // Do FS scan
+ tracing::info!("Starting filesystem scan background job");
+ let fs_instructions_sys = {
+ let ignores: Vec = script_engine
+ .state()
+ .commands()
+ .fs_ignores
+ .iter()
+ .cloned()
+ .collect();
+ let trust_mtime = cli.trust_mtime;
+ let interner = interner.clone();
+ let backends_files = backend_files.clone();
+ tokio::task::spawn_blocking(move || {
+ fs_scan::scan_fs(&interner, &backends_files, &ignores, trust_mtime)
+ })
+ };
+
+ // Script: Do early package phase
+ script_engine.run_phase(Phase::ScriptDependencies).await?;
+
+ tracing::info!("Waiting for package loading results...");
+ let (pkgs_sys, package_maps) = package_loader.await??;
+ tracing::info!("Got package loading results");
+
+ // Create the set of package managers for use by the script
+ script_engine.state_mut().setup_package_managers(
+ &backends_pkg,
+ file_backend_id,
+ &backend_files,
+ &package_maps,
+ &interner,
+ );
+
+ // Apply early packages (if any)
+ if let Commands::Apply {} = cli.command {
+ tracing::info!("Applying early packages (if any are missing)");
+ let mut applicator = create_applicator(
+ cli.confirmation,
+ cli.debug_force_dry_run,
+ &backends_pkg,
+ &interner,
+ &package_maps,
+ &backend_files,
+ script_engine.state().settings().diff(),
+ script_engine.state().settings().pager(),
+ );
+ let pkg_diff = pkgs::package_diff(&pkgs_sys, &script_engine);
+ let pkgs_changes = pkg_diff.filter_map(|v| match v {
+ itertools::EitherOrBoth::Both(_, _) => None,
+ itertools::EitherOrBoth::Left(_) => None,
+ itertools::EitherOrBoth::Right((id, instr)) => Some((id, instr.clone())),
+ });
+ apply_packages(applicator.as_mut(), pkgs_changes, &package_maps, &interner)?;
+ }
+
+ // Script: Do main phase
+ script_engine.run_phase(Phase::Main).await?;
+
+ // Make sure FS actions are sorted
+ script_engine.state_mut().commands_mut().fs_actions.sort();
+
+ tracing::info!("Waiting for file system scan results...");
+ let (fs_scan_result, fs_instructions_sys) = fs_instructions_sys.await??;
+ tracing::info!("Got file system scan results");
+
+ // Compare expected to system
+ let mut script_fs = konfigkoll_core::state::FsEntries::default();
+ let mut sys_fs = konfigkoll_core::state::FsEntries::default();
+ let fs_actions = std::mem::take(&mut script_engine.state_mut().commands_mut().fs_actions);
+ script_fs.apply_instructions(fs_actions.into_iter(), true);
+ sys_fs.apply_instructions(fs_instructions_sys.into_iter(), false);
+
+ // Packages are so much easier
+ let pkg_diff = pkgs::package_diff(&pkgs_sys, &script_engine);
+
+ // At the end, decide what we want to do with the results
+ match cli.command {
+ Commands::Save {} => {
+ tracing::info!("Saving changes");
+ // Split out additions and removals
+ let mut fs_additions =
+ konfigkoll_core::state::diff(&DiffGoal::Save, script_fs, sys_fs)?.collect_vec();
+ fs_additions.sort();
+ let mut pkg_additions = vec![];
+ let mut pkg_removals = vec![];
+ pkg_diff.for_each(|v| match v {
+ itertools::EitherOrBoth::Both(_, _) => (),
+ itertools::EitherOrBoth::Left((id, instr)) => {
+ pkg_additions.push((id, instr.clone()));
+ }
+ itertools::EitherOrBoth::Right((id, instr)) => {
+ pkg_removals.push((id, instr.inverted()));
+ }
+ });
+
+ // Open output file (for appending) in config dir
+ let output_path = config_path.join("unsorted.rn");
+ let mut output = BufWriter::new(
+ std::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(&output_path)
+ .with_context(|| format!("Failed to open output file {}", output_path))?,
+ );
+ output.write_all("// This file is generated by konfigkoll\n".as_bytes())?;
+ output.write_all(
+ "// You will need to merge the changes you want into your own actual config\n"
+ .as_bytes(),
+ )?;
+ output.write_all("pub fn unsorted_additions(props, cmds) {\n".as_bytes())?;
+ konfigkoll_core::save::save_packages(&mut output, pkg_additions.into_iter())?;
+ let files_path = config_path.join("files");
+ let sensitive_configs: AHashSet = script_engine
+ .state()
+ .settings()
+ .sensitive_configs()
+ .collect();
+ konfigkoll_core::save::save_fs_changes(
+ &mut output,
+ |path, contents| {
+ if sensitive_configs.contains(path) {
+ tracing::warn!(
+ "{} has changes, but it is marked sensitive, won't auto-save",
+ path
+ );
+ return Ok(());
+ }
+ match cli.confirmation == Paranoia::DryRun {
+ true => save::noop_file_data_saver(path),
+ false => save::file_data_saver(&files_path, path, contents),
+ }
+ },
+ fs_additions.iter(),
+ )?;
+ output.write_all("}\n".as_bytes())?;
+
+ output.write_all("\n// These are entries in your config that are not applied to the current system\n".as_bytes())?;
+ output.write_all(
+ "// Note that these may not correspond *exactly* to what is in your config\n"
+ .as_bytes(),
+ )?;
+ output.write_all("// (e.g. write and copy will get mixed up).\n".as_bytes())?;
+ output.write_all("pub fn unsorted_removals(props, cmds) {\n".as_bytes())?;
+ konfigkoll_core::save::save_packages(&mut output, pkg_removals.into_iter())?;
+ output.write_all("}\n".as_bytes())?;
+ }
+ Commands::Apply {} => {
+ tracing::info!("Applying changes");
+ let mut fs_changes = konfigkoll_core::state::diff(
+ &DiffGoal::Apply(backend_files.clone(), fs_scan_result.borrow_path_map()),
+ sys_fs,
+ script_fs,
+ )?
+ .collect_vec();
+ fs_changes.sort();
+
+ let pkgs_changes = pkg_diff.filter_map(|v| match v {
+ itertools::EitherOrBoth::Both(_, _) => None,
+ itertools::EitherOrBoth::Left((id, instr)) => Some((id, instr.inverted())),
+ itertools::EitherOrBoth::Right((id, instr)) => Some((id, instr.clone())),
+ });
+
+ let mut applicator = create_applicator(
+ cli.confirmation,
+ cli.debug_force_dry_run,
+ &backends_pkg,
+ &interner,
+ &package_maps,
+ &backend_files,
+ script_engine.state().settings().diff(),
+ script_engine.state().settings().pager(),
+ );
+
+ // Split into early / late file changes based on settings
+ let early_configs: AHashSet =
+ script_engine.state().settings().early_configs().collect();
+ let mut early_fs_changes = vec![];
+ let mut late_fs_changes = vec![];
+ for change in fs_changes {
+ if early_configs.contains(&change.path) {
+ early_fs_changes.push(change);
+ } else {
+ late_fs_changes.push(change);
+ }
+ }
+
+ // Apply early file system
+ apply_files(applicator.as_mut(), early_fs_changes.iter())?;
+
+ // Apply packages
+ apply_packages(applicator.as_mut(), pkgs_changes, &package_maps, &interner)?;
+
+ // Apply rest of file system
+ apply_files(applicator.as_mut(), late_fs_changes.iter())?;
+ }
+ Commands::Diff { path } => {
+ tracing::info!("Computing diff");
+ let mut fs_changes = konfigkoll_core::state::diff(
+ &DiffGoal::Apply(backend_files.clone(), fs_scan_result.borrow_path_map()),
+ sys_fs,
+ script_fs,
+ )?
+ .collect_vec();
+ fs_changes.sort();
+ let diff_cmd = script_engine.state().settings().diff();
+ let pager_cmd = script_engine.state().settings().pager();
+ for change in fs_changes {
+ if change.path.starts_with(&path) {
+ show_fs_instr_diff(&change, &diff_cmd, &pager_cmd)?;
+ }
+ }
+ }
+ Commands::Check {} | Commands::Init {} => unreachable!(),
+ }
+
+ Ok(())
+}
+
+fn init_directory(config_path: &Utf8Path) -> anyhow::Result<()> {
+ std::fs::create_dir_all(config_path).context("Failed to create config directory")?;
+ std::fs::create_dir_all(config_path.join("files"))?;
+
+ // Create skeleton main script
+ let main_script = config_path.join("main.rn");
+ if !main_script.exists() {
+ std::fs::write(&main_script, include_bytes!("../data/template/main.rn"))?;
+ }
+ // Create skeleton unsorted script
+ let unsorted_script = config_path.join("unsorted.rn");
+ if !unsorted_script.exists() {
+ std::fs::write(
+ &unsorted_script,
+ include_bytes!("../data/template/unsorted.rn"),
+ )?;
+ }
+ // Gitignore
+ let gitignore = config_path.join(".gitignore");
+ if !gitignore.exists() {
+ std::fs::write(&gitignore, include_bytes!("../data/template/_gitignore"))?;
+ }
+
+ // Add an empty Rune.toml
+ let runetoml = config_path.join("Rune.toml");
+ if !runetoml.exists() {
+ std::fs::write(&runetoml, b"")?;
+ }
+
+ Ok(())
+}
diff --git a/crates/konfigkoll/src/pkgs.rs b/crates/konfigkoll/src/pkgs.rs
new file mode 100644
index 00000000..68874dc2
--- /dev/null
+++ b/crates/konfigkoll/src/pkgs.rs
@@ -0,0 +1,72 @@
+//! Package scanning functions
+
+use std::{collections::BTreeMap, sync::Arc};
+
+use anyhow::Context;
+use itertools::Itertools;
+use konfigkoll_types::PkgInstructions;
+use paketkoll_types::{
+ backend::{Backend, PackageBackendMap, PackageMap, PackageMapMap},
+ intern::Interner,
+};
+use rayon::prelude::*;
+
+#[tracing::instrument(skip_all)]
+pub(crate) fn load_packages(
+ interner: &Arc,
+ backends_pkg: &PackageBackendMap,
+) -> anyhow::Result<(PkgInstructions, PackageMapMap)> {
+ let mut pkgs_sys = BTreeMap::new();
+ let mut package_maps: BTreeMap> = BTreeMap::new();
+ let backend_maps: Vec<_> = backends_pkg
+ .values()
+ .par_bridge()
+ .map(|backend| {
+ let backend_pkgs = backend
+ .packages(interner)
+ .with_context(|| {
+ format!(
+ "Failed to collect information from backend {}",
+ backend.name()
+ )
+ })
+ .map(|backend_pkgs| {
+ let pkg_map = Arc::new(paketkoll_types::backend::packages_to_package_map(
+ backend_pkgs.clone(),
+ ));
+ let pkg_instructions =
+ konfigkoll_core::conversion::convert_packages_to_pkg_instructions(
+ backend_pkgs.into_iter(),
+ backend.as_backend_enum(),
+ interner,
+ );
+ (pkg_map, pkg_instructions)
+ });
+ (backend, backend_pkgs)
+ })
+ .collect();
+ for (backend, backend_pkgs) in backend_maps.into_iter() {
+ let (backend_pkgs_map, pkg_instructions) = backend_pkgs?;
+ package_maps.insert(backend.as_backend_enum(), backend_pkgs_map);
+ pkgs_sys.extend(pkg_instructions.into_iter());
+ }
+
+ Ok((pkgs_sys, package_maps))
+}
+
+type PackagePair<'a> = (
+ &'a konfigkoll_types::PkgIdent,
+ &'a konfigkoll_types::PkgInstruction,
+);
+
+/// Get a diff of packages
+pub(crate) fn package_diff<'input>(
+ sorted_pkgs_sys: &'input PkgInstructions,
+ script_engine: &'input konfigkoll_script::ScriptEngine,
+) -> impl Iterator- , PackagePair<'input>>> {
+ let pkg_actions = &script_engine.state().commands().package_actions;
+ let left = sorted_pkgs_sys.iter();
+ let right = pkg_actions.iter().sorted();
+
+ konfigkoll_core::diff::comm(left, right)
+}
diff --git a/crates/konfigkoll/src/save.rs b/crates/konfigkoll/src/save.rs
new file mode 100644
index 00000000..fdbc56bb
--- /dev/null
+++ b/crates/konfigkoll/src/save.rs
@@ -0,0 +1,36 @@
+//! Code to save config
+
+use std::io::Write;
+
+use anyhow::Context;
+use camino::Utf8Path;
+use konfigkoll_core::utils::safe_path_join;
+use konfigkoll_types::FileContents;
+
+/// Copy files to the config directory, under the "files/".
+pub(crate) fn file_data_saver(
+ files_path: &Utf8Path,
+ path: &Utf8Path,
+ contents: &FileContents,
+) -> Result<(), anyhow::Error> {
+ tracing::info!("Saving file data for {}", path);
+ let full_path = safe_path_join(files_path, path);
+ std::fs::create_dir_all(full_path.parent().with_context(|| {
+ format!("Impossible error: joined path should always below config dir: {full_path}")
+ })?)?;
+ match contents {
+ FileContents::Literal { checksum: _, data } => {
+ let mut file = std::fs::File::create(&full_path)?;
+ file.write_all(data)?;
+ }
+ FileContents::FromFile { checksum: _, path } => {
+ std::fs::copy(path, &full_path)?;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn noop_file_data_saver(path: &Utf8Path) -> Result<(), anyhow::Error> {
+ tracing::info!("Would save file data for {}", path);
+ Ok(())
+}
diff --git a/crates/konfigkoll_core/Cargo.toml b/crates/konfigkoll_core/Cargo.toml
new file mode 100644
index 00000000..e7a5b092
--- /dev/null
+++ b/crates/konfigkoll_core/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+description = "Core functionality for Konfigkoll"
+edition = "2021"
+license = "MPL-2.0"
+name = "konfigkoll_core"
+repository = "https://github.com/VorpalBlade/paketkoll"
+rust-version = "1.79.0"
+version = "0.1.0"
+
+[dependencies]
+ahash.workspace = true
+anyhow.workspace = true
+camino.workspace = true
+clru.workspace = true
+compact_str.workspace = true
+console.workspace = true
+duct.workspace = true
+either.workspace = true
+itertools.workspace = true
+konfigkoll_types = { version = "0.1.0", path = "../konfigkoll_types" }
+libc.workspace = true
+nix = { workspace = true, features = ["user"] }
+paketkoll_types = { version = "0.1.0", path = "../paketkoll_types" }
+paketkoll_utils = { version = "0.1.0", path = "../paketkoll_utils" }
+parking_lot.workspace = true
+rayon.workspace = true
+regex.workspace = true
+smallvec.workspace = true
+strum = { workspace = true, features = ["derive"] }
+tracing.workspace = true
+
+[lints]
+workspace = true
+
+[dev-dependencies]
+indoc.workspace = true
+pretty_assertions.workspace = true
+
+[[example]]
+name = "multi_confirm_demo"
+path = "examples/multi_confirm_demo.rs"
diff --git a/crates/konfigkoll_core/README.md b/crates/konfigkoll_core/README.md
new file mode 100644
index 00000000..03af943b
--- /dev/null
+++ b/crates/konfigkoll_core/README.md
@@ -0,0 +1,5 @@
+# konfigkoll_core
+
+Core library of konfigkoll.
+
+**Warning**: This is not a stable API for public consumption.
diff --git a/crates/konfigkoll_core/examples/multi_confirm_demo.rs b/crates/konfigkoll_core/examples/multi_confirm_demo.rs
new file mode 100644
index 00000000..93b00af5
--- /dev/null
+++ b/crates/konfigkoll_core/examples/multi_confirm_demo.rs
@@ -0,0 +1,19 @@
+use console::Style;
+use konfigkoll_core::confirm::MultiOptionConfirm;
+
+fn main() -> anyhow::Result<()> {
+ let mut builder = MultiOptionConfirm::builder();
+ builder
+ .prompt("Are you sure?")
+ .option('y', "Yes")
+ .option('n', "No")
+ .option('d', "show Diff")
+ .prompt_style(Style::new().green())
+ .options_style(Style::new().cyan())
+ .default_option_style(Style::new().cyan().underlined())
+ .default('N');
+ let confirm = builder.build();
+ let result = confirm.prompt()?;
+ dbg!(result);
+ Ok(())
+}
diff --git a/crates/konfigkoll_core/src/apply.rs b/crates/konfigkoll_core/src/apply.rs
new file mode 100644
index 00000000..7e50bdc8
--- /dev/null
+++ b/crates/konfigkoll_core/src/apply.rs
@@ -0,0 +1,518 @@
+//! Apply a stream of instructions to the current system
+
+use std::{collections::BTreeMap, fs::Permissions, os::unix::fs::PermissionsExt, sync::Arc};
+
+use ahash::AHashMap;
+use anyhow::Context;
+use either::Either;
+use itertools::Itertools;
+use konfigkoll_types::{FsInstruction, FsOp, FsOpDiscriminants, PkgIdent, PkgInstruction, PkgOp};
+use paketkoll_types::{
+ backend::{Backend, Files, OriginalFileQuery, PackageBackendMap, PackageMap, PackageMapMap},
+ intern::{Interner, PackageRef},
+};
+
+use crate::{
+ confirm::MultiOptionConfirm,
+ diff::show_fs_instr_diff,
+ utils::{IdKey, NameToNumericResolveCache},
+};
+use console::style;
+
+/// Applier of system changes
+///
+/// Different implementors of this trait handle things like:
+/// * Privilege separation
+/// * Interactive confirmation
+/// * Actual applying to the system
+pub trait Applicator {
+ /// Apply package changes
+ fn apply_pkgs<'instructions>(
+ &mut self,
+ backend: Backend,
+ install: &[&'instructions str],
+ mark_explicit: &[&'instructions str],
+ uninstall: &[&'instructions str],
+ ) -> anyhow::Result<()>;
+
+ /// Apply file changes
+ fn apply_files(&mut self, instructions: &[&FsInstruction]) -> anyhow::Result<()>;
+}
+
+impl Applicator for Either
+where
+ L: Applicator,
+ R: Applicator,
+{
+ fn apply_pkgs<'instructions>(
+ &mut self,
+ backend: Backend,
+ install: &[&'instructions str],
+ mark_explicit: &[&'instructions str],
+ uninstall: &[&'instructions str],
+ ) -> anyhow::Result<()> {
+ match self {
+ Either::Left(inner) => inner.apply_pkgs(backend, install, mark_explicit, uninstall),
+ Either::Right(inner) => inner.apply_pkgs(backend, install, mark_explicit, uninstall),
+ }
+ }
+
+ fn apply_files(&mut self, instructions: &[&FsInstruction]) -> anyhow::Result<()> {
+ match self {
+ Either::Left(inner) => inner.apply_files(instructions),
+ Either::Right(inner) => inner.apply_files(instructions),
+ }
+ }
+}
+
+/// Apply with no privilege separation
+#[derive(Debug)]
+pub struct InProcessApplicator {
+ package_backends: PackageBackendMap,
+ file_backend: Arc,
+ interner: Arc,
+ package_maps: BTreeMap>,
+ id_resolver: NameToNumericResolveCache,
+}
+
+impl InProcessApplicator {
+ pub fn new(
+ package_backends: PackageBackendMap,
+ interner: &Arc,
+ package_maps: &BTreeMap>,
+ file_backend: &Arc,
+ ) -> Self {
+ Self {
+ package_backends,
+ file_backend: file_backend.clone(),
+ interner: Arc::clone(interner),
+ package_maps: package_maps.clone(),
+ id_resolver: NameToNumericResolveCache::new(),
+ }
+ }
+}
+
+impl Applicator for InProcessApplicator {
+ fn apply_pkgs<'instructions>(
+ &mut self,
+ backend: Backend,
+ install: &[&'instructions str],
+ mark_explicit: &[&'instructions str],
+ uninstall: &[&'instructions str],
+ ) -> anyhow::Result<()> {
+ tracing::info!(
+ "Proceeding with installing {:?} and uninstalling {:?} with backend {:?}",
+ install,
+ uninstall,
+ backend
+ );
+ let backend = self
+ .package_backends
+ .get(&backend)
+ .ok_or_else(|| anyhow::anyhow!("Unknown backend: {:?}", backend))?;
+
+ tracing::info!("Installing packages...");
+ backend.transact(install, &[], true)?;
+ tracing::info!("Marking packages explicit...");
+ backend.mark(&[], mark_explicit)?;
+ tracing::info!("Attempting to mark unwanted packages as dependencies...");
+ match backend.mark(uninstall, &[]) {
+ Ok(()) => {
+ tracing::info!("Successfully marked unwanted packages as dependencies");
+ tracing::info!("Removing unused packages...");
+ backend.remove_unused(true)?;
+ }
+ Err(paketkoll_types::backend::PackageManagerError::UnsupportedOperation(_)) => {
+ tracing::info!("Marking unwanted packages as dependencies not supported, using uninstall instead");
+ backend.transact(&[], uninstall, true)?;
+ }
+ Err(e) => return Err(e.into()),
+ }
+
+ Ok(())
+ }
+
+ fn apply_files(&mut self, instructions: &[&FsInstruction]) -> anyhow::Result<()> {
+ let pkg_map = self
+ .package_maps
+ .get(&self.file_backend.as_backend_enum())
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "No package map for file backend {:?}",
+ self.file_backend.as_backend_enum()
+ )
+ })?;
+ for instr in instructions {
+ tracing::info!("Applying: {}: {}", instr.path, instr.op);
+ match &instr.op {
+ FsOp::Remove => {
+ let existing = std::fs::symlink_metadata(&instr.path);
+ if let Ok(metadata) = existing {
+ if metadata.is_dir() {
+ match std::fs::remove_dir(&instr.path) {
+ Ok(_) => (),
+ Err(err) => match err.raw_os_error() {
+ Some(libc::ENOTEMPTY) => {
+ Err(err).context("Failed to remove directory: it is not empty (possibly it contains some ignored files). You will have to investigate and resolve this yourself, since we don't want to delete things we shouldn't.")?;
+ }
+ Some(_) | None => {
+ Err(err).context("Failed to remove directory")?;
+ }
+ },
+ }
+ } else {
+ std::fs::remove_file(&instr.path)?;
+ }
+ }
+ }
+ FsOp::CreateDirectory => {
+ std::fs::create_dir_all(&instr.path)?;
+ }
+ FsOp::CreateFile(contents) => match contents {
+ konfigkoll_types::FileContents::Literal { checksum: _, data } => {
+ std::fs::write(&instr.path, data)?;
+ }
+ konfigkoll_types::FileContents::FromFile { checksum: _, path } => {
+ std::fs::copy(path, &instr.path)?;
+ }
+ },
+ FsOp::CreateSymlink { target } => {
+ std::os::unix::fs::symlink(target, &instr.path)?;
+ }
+ FsOp::CreateFifo => {
+ // Since we split out mode in general, we don't know what to put here.
+ // Use empty, and let later instructions set it correctly.
+ nix::unistd::mkfifo(instr.path.as_std_path(), nix::sys::stat::Mode::empty())?;
+ }
+ FsOp::CreateBlockDevice { major, minor } => {
+ // Like with fifo, we don't know mode yet.
+ nix::sys::stat::mknod(
+ instr.path.as_std_path(),
+ nix::sys::stat::SFlag::S_IFBLK,
+ nix::sys::stat::Mode::empty(),
+ nix::sys::stat::makedev(*major, *minor),
+ )?;
+ }
+ FsOp::CreateCharDevice { major, minor } => {
+ // Like with fifo, we don't know mode yet.
+ nix::sys::stat::mknod(
+ instr.path.as_std_path(),
+ nix::sys::stat::SFlag::S_IFCHR,
+ nix::sys::stat::Mode::empty(),
+ nix::sys::stat::makedev(*major, *minor),
+ )?;
+ }
+ FsOp::SetMode { mode } => {
+ let perms = Permissions::from_mode(mode.as_raw());
+ std::fs::set_permissions(&instr.path, perms)?;
+ }
+ FsOp::SetOwner { owner } => {
+ let uid = nix::unistd::Uid::from_raw(
+ self.id_resolver.lookup(&IdKey::User(owner.clone()))?,
+ );
+ nix::unistd::chown(instr.path.as_std_path(), Some(uid), None)?;
+ }
+ FsOp::SetGroup { group } => {
+ let gid = nix::unistd::Gid::from_raw(
+ self.id_resolver.lookup(&IdKey::Group(group.clone()))?,
+ );
+ nix::unistd::chown(instr.path.as_std_path(), None, Some(gid))?;
+ }
+ FsOp::Restore => {
+ // Get package:
+ let owners = self
+ .file_backend
+ .owning_packages(&[instr.path.as_std_path()].into(), &self.interner)
+ .with_context(|| format!("Failed to find owner for {}", instr.path))?;
+ let package = owners
+ .get(instr.path.as_std_path())
+ .with_context(|| format!("Failed to find owner for {}", instr.path))?
+ .ok_or_else(|| anyhow::anyhow!("No owner for {}", instr.path))?;
+ let package = package.to_str(&self.interner);
+ // Get original contents:
+ let queries = [OriginalFileQuery {
+ package: package.into(),
+ path: instr.path.as_str().into(),
+ }];
+ let original_contents =
+ self.file_backend
+ .original_files(&queries, pkg_map, &self.interner)?;
+ // Apply
+ for query in queries {
+ let contents = original_contents.get(&query).ok_or_else(|| {
+ anyhow::anyhow!("No original contents for {:?}", query)
+ })?;
+ std::fs::write(&instr.path, contents)?;
+ }
+ }
+ FsOp::Comment => (),
+ }
+ }
+ Ok(())
+ }
+}
+
+/// An applicator that asks for confirmation before applying changes
+#[derive(Debug)]
+pub struct InteractiveApplicator {
+ inner: Inner,
+ pkg_confirmer: MultiOptionConfirm,
+ fs_confirmer: MultiOptionConfirm,
+ interactive_confirmer: MultiOptionConfirm,
+ diff_command: Vec,
+ pager_command: Vec,
+}
+
+impl InteractiveApplicator {
+ pub fn new(inner: Inner, diff_command: Vec, pager_command: Vec) -> Self {
+ let mut prompt_builder = MultiOptionConfirm::builder();
+ prompt_builder
+ .prompt("Do you want to apply these changes?")
+ .option('y', "Yes")
+ .option('n', "No")
+ .option('d', "show Diff");
+ let pkg_confirmer = prompt_builder.build();
+ prompt_builder.option('i', "Interactive (change by change)");
+ let fs_confirmer = prompt_builder.build();
+
+ let mut prompt_builder = MultiOptionConfirm::builder();
+ prompt_builder
+ .prompt("Apply changes to this file?")
+ .option('y', "Yes")
+ .option('a', "Abort")
+ .option('s', "Skip")
+ .option('d', "show Diff");
+ let interactive_confirmer = prompt_builder.build();
+
+ Self {
+ inner,
+ pkg_confirmer,
+ fs_confirmer,
+ interactive_confirmer,
+ diff_command,
+ pager_command,
+ }
+ }
+}
+
+impl Applicator for InteractiveApplicator {
+ fn apply_pkgs<'instructions>(
+ &mut self,
+ backend: Backend,
+ install: &[&'instructions str],
+ mark_explicit: &[&'instructions str],
+ uninstall: &[&'instructions str],
+ ) -> anyhow::Result<()> {
+ tracing::info!(
+ "Will install {:?}, mark {:?} as explicit and uninstall {:?} with backend {backend}",
+ install.len(),
+ mark_explicit.len(),
+ uninstall.len(),
+ );
+
+ loop {
+ match self.pkg_confirmer.prompt()? {
+ 'y' => {
+ tracing::info!("Applying changes");
+ return self
+ .inner
+ .apply_pkgs(backend, install, mark_explicit, uninstall);
+ }
+ 'n' => {
+ tracing::info!("Aborting");
+ return Err(anyhow::anyhow!("User aborted"));
+ }
+ 'd' => {
+ println!("With package manager {backend}:");
+ for pkg in install {
+ println!(" {} {}", style("+").green(), pkg);
+ }
+ for pkg in mark_explicit {
+ println!(" {} {} (mark explicit)", style("E").green(), pkg);
+ }
+ for pkg in uninstall {
+ println!(" {} {}", style("-").red(), pkg);
+ }
+ }
+ _ => return Err(anyhow::anyhow!("Unexpected branch (internal error)")),
+ }
+ }
+ }
+
+ fn apply_files(&mut self, instructions: &[&FsInstruction]) -> anyhow::Result<()> {
+ tracing::info!("Will apply {} file instructions", instructions.len());
+ loop {
+ match self.fs_confirmer.prompt()? {
+ 'y' => {
+ tracing::info!("Applying changes");
+ return self.inner.apply_files(instructions);
+ }
+ 'n' => {
+ tracing::info!("Aborting");
+ return Err(anyhow::anyhow!("User aborted"));
+ }
+ 'd' => {
+ println!("With file system:");
+ for instr in instructions {
+ println!(" {}: {}", style(instr.path.as_str()).blue(), instr.op);
+ }
+ }
+ 'i' => {
+ for instr in instructions {
+ self.interactive_apply_single_file(instr)?;
+ }
+ return Ok(());
+ }
+ _ => return Err(anyhow::anyhow!("Unexpected branch (internal error)")),
+ }
+ }
+ }
+}
+
+impl InteractiveApplicator {
+ fn interactive_apply_single_file(
+ &mut self,
+ instr: &&FsInstruction,
+ ) -> Result<(), anyhow::Error> {
+ println!(
+ "Under consideration: {} with change {}",
+ style(instr.path.as_str()).blue(),
+ instr.op
+ );
+ loop {
+ match self.interactive_confirmer.prompt()? {
+ 'y' => {
+ tracing::info!("Applying change to {}", instr.path);
+ return self.inner.apply_files(&[instr]);
+ }
+ 'a' => {
+ tracing::info!("Aborting");
+ return Err(anyhow::anyhow!("User aborted"));
+ }
+ 's' => {
+ tracing::info!("Skipping {}", instr.path);
+ return Ok(());
+ }
+ 'd' => {
+ show_fs_instr_diff(
+ instr,
+ self.diff_command.as_slice(),
+ self.pager_command.as_slice(),
+ )?;
+ }
+ _ => return Err(anyhow::anyhow!("Unexpected branch (internal error)")),
+ };
+ }
+ }
+}
+
+/// Just print, don't actually apply.
+#[derive(Debug, Default)]
+pub struct NoopApplicator {}
+
+impl Applicator for NoopApplicator {
+ fn apply_pkgs<'instructions>(
+ &mut self,
+ backend: Backend,
+ install: &[&'instructions str],
+ mark_explicit: &[&'instructions str],
+ uninstall: &[&'instructions str],
+ ) -> anyhow::Result<()> {
+ tracing::info!(
+ "Would install {:?}, mark {:?} explicit and uninstall {:?} with backend {:?}",
+ install.len(),
+ mark_explicit.len(),
+ uninstall.len(),
+ backend
+ );
+
+ for pkg in install {
+ tracing::info!(" + {}", pkg);
+ }
+ for pkg in mark_explicit {
+ tracing::info!(" {} (mark explicit)", pkg);
+ }
+ for pkg in uninstall {
+ tracing::info!(" - {}", pkg);
+ }
+ Ok(())
+ }
+
+ fn apply_files(&mut self, instructions: &[&FsInstruction]) -> anyhow::Result<()> {
+ tracing::info!("Would apply {} file instructions", instructions.len());
+ for instr in instructions {
+ tracing::info!(" {}: {}", instr.path, instr.op);
+ }
+ Ok(())
+ }
+}
+
+pub fn apply_files<'instructions>(
+ applicator: &mut dyn Applicator,
+ instructions: impl Iterator
- ,
+) -> anyhow::Result<()> {
+ // Sort and group by type of operation, to make changes easier to review
+ let instructions = instructions
+ .sorted_by(|a, b| a.op.cmp(&b.op).then_with(|| a.path.cmp(&b.path)))
+ .collect_vec();
+ let chunked_instructions = instructions
+ .iter()
+ .chunk_by(|e| FsOpDiscriminants::from(&e.op));
+ // Process each chunk separately
+ for (_discr, chunk) in chunked_instructions.into_iter() {
+ let chunk = chunk.cloned().collect_vec();
+ // Removing things has to be sorted reverse, so we remove contents before the directory they are containers of
+ let chunk = match chunk[0].op {
+ FsOp::Remove => chunk.into_iter().rev().collect_vec(),
+ _ => chunk,
+ };
+ applicator.apply_files(chunk.as_slice())?;
+ }
+ Ok(())
+}
+
+#[derive(Default)]
+struct PackageOperations<'a> {
+ install: Vec<&'a str>,
+ mark_as_manual: Vec<&'a str>,
+ uninstall: Vec<&'a str>,
+}
+
+/// Apply package changes
+pub fn apply_packages<'instructions>(
+ applicator: &mut dyn Applicator,
+ instructions: impl Iterator
- ,
+ package_maps: &PackageMapMap,
+ interner: &Interner,
+) -> anyhow::Result<()> {
+ // Sort into backends
+ let mut sorted = AHashMap::new();
+ for (pkg, instr) in instructions {
+ let backend = pkg.package_manager;
+ let entry = sorted
+ .entry(backend)
+ .or_insert_with(PackageOperations::default);
+ let sub_map = package_maps
+ .get(&backend)
+ .ok_or_else(|| anyhow::anyhow!("No package map for backend {:?}", backend))?;
+ // Deal with the case where a package is installed as a dependency and we want it explicit
+ let pkg_ref = PackageRef::get_or_intern(interner, pkg.identifier.as_str());
+ let has_pkg = sub_map.get(&pkg_ref).is_some();
+ match (instr.op, has_pkg) {
+ (PkgOp::Install, true) => entry.mark_as_manual.push(pkg.identifier.as_str()),
+ (PkgOp::Install, false) => entry.install.push(pkg.identifier.as_str()),
+ (PkgOp::Uninstall, _) => entry.uninstall.push(pkg.identifier.as_str()),
+ }
+ }
+
+ // Apply with applicator
+ for (backend, operations) in sorted {
+ applicator.apply_pkgs(
+ backend,
+ &operations.install,
+ &operations.mark_as_manual,
+ &operations.uninstall,
+ )?;
+ }
+ Ok(())
+}
diff --git a/crates/konfigkoll_core/src/confirm.rs b/crates/konfigkoll_core/src/confirm.rs
new file mode 100644
index 00000000..f2a0bd04
--- /dev/null
+++ b/crates/konfigkoll_core/src/confirm.rs
@@ -0,0 +1,196 @@
+//! Allows asking for confirmation in the CLI
+
+use std::io::Write;
+
+use ahash::AHashSet;
+use compact_str::{CompactString, ToCompactString};
+use console::{Key, Style, Term};
+use itertools::Itertools;
+
+/// A simple multiple choice prompt. Will look something like:
+///
+/// ```text
+/// Are you sure? [Yes/No/show Diff]
+/// ```
+///
+/// Letters that trigger:
+/// * Must be unique
+/// * Must be available as a unique code point in both upper and lower case.
+/// * The convention is to put the trigger letter in uppercase in the string for the option.
+#[derive(Debug, Clone)]
+pub struct MultiOptionConfirm {
+ prompt: CompactString,
+ default: Option,
+ options: AHashSet,
+}
+
+impl MultiOptionConfirm {
+ /// Create a builder for this type
+ pub fn builder() -> MultiOptionConfirmBuilder {
+ MultiOptionConfirmBuilder::new()
+ }
+
+ /// Run the prompt and return the user choice
+ pub fn prompt(&self) -> anyhow::Result {
+ let mut term = Term::stdout();
+ loop {
+ term.write_all(self.prompt.as_bytes())?;
+ let key = term.read_key()?;
+ match key {
+ Key::Char(c) => term.write_line(format!("{c}").as_str())?,
+ _ => term.write_line("")?,
+ }
+
+ match key {
+ console::Key::Enter => {
+ if let Some(default) = self.default {
+ return Ok(default);
+ } else {
+ term.write_line("Please select an option (this prompt has no default)")?;
+ }
+ }
+ console::Key::Char(c) => {
+ let lower_case: AHashSet<_> = c.to_lowercase().collect();
+ let found = self.options.intersection(&lower_case).count() > 0;
+ if found {
+ return Ok(c);
+ } else {
+ term.write_line("Invalid option, try again")?;
+ }
+ }
+ console::Key::Escape => {
+ term.write_line("Aborted")?;
+ anyhow::bail!("User aborted with Escape");
+ }
+ console::Key::CtrlC => {
+ term.write_line("Aborted")?;
+ anyhow::bail!("User aborted with Ctrl-C");
+ }
+ _ => {
+ term.write_line("Unkown input, try again")?;
+ }
+ }
+ }
+ }
+}
+
+/// Builder for [`MultiOptionConfirm`].
+///
+/// Use [`MultiOptionConfirm::builder()`] to create a new instance.
+///
+/// The default style uses colours and highlights the default option with bold.
+#[derive(Debug, Clone)]
+pub struct MultiOptionConfirmBuilder {
+ prompt: Option,
+ default: Option,
+ prompt_style: Style,
+ options_style: Style,
+ default_option_style: Style,
+ options: Vec<(char, CompactString)>,
+}
+
+impl MultiOptionConfirmBuilder {
+ fn new() -> Self {
+ Self {
+ prompt: None,
+ default: None,
+ prompt_style: Style::new().green(),
+ options_style: Style::new().cyan(),
+ default_option_style: Style::new().cyan().bold(),
+ options: Vec::new(),
+ }
+ }
+
+ /// Set prompt to use. Required.
+ pub fn prompt(&mut self, prompt: &str) -> &mut Self {
+ self.prompt = Some(prompt.to_compact_string());
+ self
+ }
+
+ /// Set default choice. Optional.
+ pub fn default(&mut self, default: char) -> &mut Self {
+ self.default = Some(
+ default
+ .to_lowercase()
+ .next()
+ .expect("Letter is not available as lower case"),
+ );
+ self
+ }
+
+ /// Add an option. At least two are required.
+ pub fn option(&mut self, key: char, value: &str) -> &mut Self {
+ self.options.push((
+ key.to_lowercase()
+ .next()
+ .expect("Letter is not available as lower case"),
+ value.to_compact_string(),
+ ));
+ self
+ }
+
+ /// Set style for question part of the prompt.
+ pub fn prompt_style(&mut self, style: Style) -> &mut Self {
+ self.prompt_style = style;
+ self
+ }
+
+ /// Set style for the options.
+ pub fn options_style(&mut self, style: Style) -> &mut Self {
+ self.options_style = style;
+ self
+ }
+
+ /// Set style for the default option.
+ pub fn default_option_style(&mut self, style: Style) -> &mut Self {
+ self.default_option_style = style;
+ self
+ }
+
+ fn render_prompt(&self) -> CompactString {
+ let mut prompt = self
+ .prompt_style
+ .apply_to(&self.prompt.as_ref().expect("A prompt must be set"))
+ .to_compact_string();
+
+ prompt.push_str(
+ self.options_style
+ .apply_to(" [")
+ .to_compact_string()
+ .as_str(),
+ );
+ let formatted = self.options.iter().map(|(key, description)| {
+ if Some(*key) == self.default {
+ self.default_option_style
+ .apply_to(description)
+ .to_compact_string()
+ } else {
+ self.options_style.apply_to(description).to_compact_string()
+ }
+ });
+ let options = Itertools::intersperse(
+ formatted,
+ self.options_style.apply_to("/").to_compact_string(),
+ )
+ .collect::();
+ prompt.push_str(options.as_str());
+ prompt.push_str(
+ self.options_style
+ .apply_to("] ")
+ .to_compact_string()
+ .as_str(),
+ );
+ prompt
+ }
+
+ pub fn build(&self) -> MultiOptionConfirm {
+ if self.options.len() < 2 {
+ panic!("At least two options are required");
+ }
+ MultiOptionConfirm {
+ prompt: self.render_prompt(),
+ default: self.default,
+ options: self.options.iter().map(|(key, _)| *key).collect(),
+ }
+ }
+}
diff --git a/crates/konfigkoll_core/src/conversion.rs b/crates/konfigkoll_core/src/conversion.rs
new file mode 100644
index 00000000..158de668
--- /dev/null
+++ b/crates/konfigkoll_core/src/conversion.rs
@@ -0,0 +1,426 @@
+//! Conversion from paketkoll issues into konfigkoll instruction stream
+
+use std::{
+ fs::File,
+ io::{BufReader, Read, Seek},
+ os::unix::fs::{FileTypeExt, MetadataExt},
+ sync::atomic::AtomicU32,
+};
+
+use anyhow::Context;
+use camino::Utf8Path;
+use compact_str::format_compact;
+use konfigkoll_types::{
+ FileContents, FsInstruction, FsOp, PkgIdent, PkgInstruction, PkgInstructions, PkgOp,
+};
+use paketkoll_types::{
+ backend::Backend,
+ files::{Checksum, Gid, Mode, Uid},
+ intern::{Interner, PackageRef},
+ issue::Issue,
+ package::{InstallReason, PackageInterned},
+};
+use paketkoll_utils::{checksum::sha256_readable, MODE_MASK};
+use parking_lot::Mutex;
+use rayon::prelude::*;
+
+use crate::utils::{IdKey, NumericToNameResolveCache};
+
+pub fn convert_issues_to_fs_instructions(
+ issues: Vec<(Option, Issue)>,
+) -> anyhow::Result> {
+ tracing::debug!("Starting conversion of {} issues", issues.len());
+ let error_count = AtomicU32::new(0);
+ let id_resolver = Mutex::new(NumericToNameResolveCache::new());
+
+ let converted: Vec = issues
+ .into_par_iter()
+ .map(|issue| {
+ let mut results = vec![];
+ let (_pkg, issue) = issue;
+ match convert_issue(&issue, &mut results, &id_resolver) {
+ Ok(()) => (),
+ Err(err) => {
+ tracing::error!(
+ "Error converting issue: {err:?} for {}",
+ issue.path().display()
+ );
+ error_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ }
+ }
+ results
+ })
+ .flatten()
+ .collect();
+
+ tracing::debug!("Conversion done, length: {}", converted.len());
+ let error_count = error_count.load(std::sync::atomic::Ordering::Relaxed);
+ if error_count > 0 {
+ anyhow::bail!("{error_count} errors were encountered while converting, see log");
+ }
+
+ Ok(converted)
+}
+
+fn convert_issue(
+ issue: &Issue,
+ results: &mut Vec,
+ id_resolver: &Mutex,
+) -> Result<(), anyhow::Error> {
+ let path: &Utf8Path = issue.path().try_into()?;
+ for kind in issue.kinds() {
+ match kind {
+ paketkoll_types::issue::IssueKind::Missing => results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::Remove,
+ comment: None,
+ }),
+ paketkoll_types::issue::IssueKind::Exists
+ | paketkoll_types::issue::IssueKind::Unexpected => {
+ results.extend(from_fs(path, id_resolver)?);
+ }
+ paketkoll_types::issue::IssueKind::PermissionDenied => {
+ anyhow::bail!("Permission denied on {:?}", issue.path());
+ }
+ paketkoll_types::issue::IssueKind::TypeIncorrect {
+ actual: _,
+ expected: _,
+ } => {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::Remove,
+ comment: Some(format_compact!("Removed due to type confict")),
+ });
+ results.extend(from_fs(path, id_resolver)?);
+ }
+ paketkoll_types::issue::IssueKind::SizeIncorrect { .. } => {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateFile(
+ fs_load_contents(path, None)
+ .with_context(|| format!("Failed to read {path:?}"))?,
+ ),
+ comment: None,
+ });
+ }
+ paketkoll_types::issue::IssueKind::ChecksumIncorrect {
+ actual,
+ expected: _,
+ } => {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateFile(
+ fs_load_contents(path, Some(actual))
+ .with_context(|| format!("Failed to read {path:?}"))?,
+ ),
+ comment: None,
+ });
+ }
+ paketkoll_types::issue::IssueKind::SymlinkTarget {
+ actual,
+ expected: _,
+ } => {
+ let actual: &Utf8Path = actual.as_path().try_into()?;
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateSymlink {
+ target: actual.into(),
+ },
+ comment: None,
+ });
+ }
+ paketkoll_types::issue::IssueKind::WrongOwner {
+ actual,
+ expected: _,
+ } => results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetOwner {
+ owner: id_resolver.lock().lookup(&IdKey::User(*actual))?,
+ },
+ comment: None,
+ }),
+ paketkoll_types::issue::IssueKind::WrongGroup {
+ actual,
+ expected: _,
+ } => results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetGroup {
+ group: id_resolver.lock().lookup(&IdKey::Group(*actual))?,
+ },
+ comment: None,
+ }),
+ paketkoll_types::issue::IssueKind::WrongMode {
+ actual,
+ expected: _,
+ } => results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetMode { mode: *actual },
+ comment: None,
+ }),
+ paketkoll_types::issue::IssueKind::WrongDeviceNodeId {
+ actual: (dev_type, major, minor),
+ expected: _,
+ } => results.push(FsInstruction {
+ path: path.into(),
+ op: match dev_type {
+ paketkoll_types::files::DeviceType::Block => FsOp::CreateBlockDevice {
+ major: *major,
+ minor: *minor,
+ },
+ paketkoll_types::files::DeviceType::Char => FsOp::CreateCharDevice {
+ major: *major,
+ minor: *minor,
+ },
+ },
+ comment: None,
+ }),
+ paketkoll_types::issue::IssueKind::MetadataError(_) => todo!(),
+ paketkoll_types::issue::IssueKind::FsCheckError(_) => todo!(),
+ _ => todo!(),
+ };
+ }
+ Ok(())
+}
+
+/// Create all required instructions for a file on the file system
+fn from_fs(
+ path: &Utf8Path,
+ id_resolver: &Mutex,
+) -> anyhow::Result> {
+ let metadata = path
+ .symlink_metadata()
+ .with_context(|| anyhow::anyhow!("Failed to get metadata"))?;
+
+ let mut results = vec![];
+
+ if metadata.is_file() {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateFile(
+ fs_load_contents(path, None).with_context(|| format!("Failed to load {path}"))?,
+ ),
+ comment: None,
+ });
+ } else if metadata.is_dir() {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateDirectory,
+ comment: None,
+ });
+ } else if metadata.file_type().is_symlink() {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateSymlink {
+ target: std::fs::read_link(path)
+ .with_context(|| anyhow::anyhow!("Failed to read symlink target"))?
+ .try_into()?,
+ },
+ comment: None,
+ });
+ } else if metadata.file_type().is_fifo() {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateFifo,
+ comment: None,
+ });
+ } else if metadata.file_type().is_block_device() {
+ let rdev = metadata.rdev();
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateBlockDevice {
+ // SAFETY: rdev is a valid device number
+ major: unsafe { libc::major(rdev) } as u64,
+ // SAFETY: rdev is a valid device number
+ minor: unsafe { libc::minor(rdev) } as u64,
+ },
+ comment: None,
+ });
+ } else if metadata.file_type().is_char_device() {
+ let rdev = metadata.rdev();
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::CreateCharDevice {
+ // SAFETY: rdev is a valid device number
+ major: unsafe { libc::major(rdev) } as u64,
+ // SAFETY: rdev is a valid device number
+ minor: unsafe { libc::minor(rdev) } as u64,
+ },
+ comment: None,
+ });
+ } else if metadata.file_type().is_socket() {
+ // Socket files can only be created by a running program and gets
+ // removed on program end. We can't do anything with them.
+ tracing::warn!("Ignoring socket file: {:?}", path);
+ return Ok(results.into_iter());
+ } else {
+ anyhow::bail!("Unsupported file type: {:?}", path);
+ }
+
+ // Set metadata
+ if !metadata.is_symlink() {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetMode {
+ mode: Mode::new(metadata.mode() & MODE_MASK),
+ },
+ comment: None,
+ });
+ }
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetOwner {
+ owner: id_resolver
+ .lock()
+ .lookup(&IdKey::User(Uid::new(metadata.uid())))?,
+ },
+ comment: None,
+ });
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetGroup {
+ group: id_resolver
+ .lock()
+ .lookup(&IdKey::Group(Gid::new(metadata.gid())))?,
+ },
+ comment: None,
+ });
+
+ Ok(results.into_iter())
+}
+
+/// Load real contents from file system
+fn fs_load_contents(path: &Utf8Path, checksum: Option<&Checksum>) -> anyhow::Result {
+ let mut reader = BufReader::new(File::open(path)?);
+ // Always use sha256, recompute if we were given an MD5.
+ // This is needed to normalise the checksums for diffing later on.
+ let checksum = match checksum {
+ Some(c @ Checksum::Sha256(_)) => c.clone(),
+ Some(_) | None => sha256_readable(&mut reader)?,
+ };
+ let size = path.metadata()?.size();
+ // I don't like this, but I don't see much of a better option to avoid running out of memory
+ if size > 1024 * 1024 {
+ Ok(FileContents::FromFile {
+ checksum,
+ path: path.into(),
+ })
+ } else {
+ reader.rewind()?;
+ let mut buf = Vec::with_capacity(size as usize);
+ reader.read_to_end(&mut buf)?;
+ Ok(FileContents::Literal {
+ checksum,
+ data: buf.into_boxed_slice(),
+ })
+ }
+}
+
+pub fn convert_packages_to_pkg_instructions(
+ packages: impl Iterator
- ,
+ package_manager: Backend,
+ interner: &Interner,
+) -> PkgInstructions {
+ let mut results = PkgInstructions::default();
+
+ for package in packages {
+ // We only consider explicitly installed packages
+ if package.reason == Some(InstallReason::Dependency) {
+ continue;
+ }
+ let identifier = if package.ids.is_empty() {
+ package.name.to_str(interner).into()
+ } else {
+ package.ids[0].to_str(interner).into()
+ };
+ results.insert(
+ PkgIdent {
+ package_manager,
+ identifier,
+ },
+ PkgInstruction {
+ op: PkgOp::Install,
+ comment: package.desc.clone(),
+ },
+ );
+ }
+
+ results
+}
+
+#[cfg(test)]
+mod tests {
+ use itertools::Itertools;
+ use paketkoll_types::package::PackageInstallStatus;
+
+ use super::*;
+
+ #[test]
+ fn test_convert_packages_to_pkg_instructions() {
+ let interner = Interner::new();
+ let packages = vec![
+ PackageInterned {
+ name: PackageRef::get_or_intern(&interner, "foo"),
+ version: "1.0".into(),
+ desc: Some("A package".into()),
+ depends: vec![],
+ provides: vec![],
+ reason: Some(InstallReason::Explicit),
+ status: PackageInstallStatus::Installed,
+ ids: smallvec::smallvec![],
+ architecture: None,
+ },
+ PackageInterned {
+ name: PackageRef::get_or_intern(&interner, "bar"),
+ version: "1.0".into(),
+ desc: Some("Another package".into()),
+ depends: vec![],
+ provides: vec![],
+ reason: Some(InstallReason::Dependency),
+ status: PackageInstallStatus::Installed,
+ ids: smallvec::smallvec![],
+ architecture: None,
+ },
+ PackageInterned {
+ name: PackageRef::get_or_intern(&interner, "quux"),
+ architecture: None,
+ version: "2.0".into(),
+ desc: Some("Yet another package".into()),
+ depends: vec![],
+ provides: vec![],
+ reason: Some(InstallReason::Explicit),
+ status: PackageInstallStatus::Installed,
+ ids: smallvec::smallvec![PackageRef::get_or_intern(&interner, "quux/x86-64")],
+ },
+ ];
+
+ let instructions =
+ convert_packages_to_pkg_instructions(packages.into_iter(), Backend::Apt, &interner);
+
+ assert_eq!(instructions.len(), 2);
+ assert_eq!(
+ instructions.iter().sorted().collect::>(),
+ vec![
+ (
+ &PkgIdent {
+ package_manager: Backend::Apt,
+ identifier: "foo".into()
+ },
+ &PkgInstruction {
+ op: PkgOp::Install,
+ comment: Some("A package".into())
+ }
+ ),
+ (
+ &PkgIdent {
+ package_manager: Backend::Apt,
+ identifier: "quux/x86-64".into()
+ },
+ &PkgInstruction {
+ op: PkgOp::Install,
+ comment: Some("Yet another package".into())
+ }
+ )
+ ]
+ );
+ }
+}
diff --git a/crates/konfigkoll_core/src/diff.rs b/crates/konfigkoll_core/src/diff.rs
new file mode 100644
index 00000000..2e36d9de
--- /dev/null
+++ b/crates/konfigkoll_core/src/diff.rs
@@ -0,0 +1,209 @@
+//! Diff two sets of instructions
+//!
+//! This module implements a generic algorithm similar to comm(1)
+
+use std::{
+ iter::FusedIterator,
+ os::unix::fs::{MetadataExt, PermissionsExt},
+};
+
+use camino::{Utf8Path, Utf8PathBuf};
+use console::style;
+use itertools::{EitherOrBoth, Itertools};
+use konfigkoll_types::{FsInstruction, FsOp};
+use paketkoll_utils::MODE_MASK;
+
+/// Compare two sorted slices of items
+pub fn comm(left: L, right: R) -> impl FusedIterator
- >
+where
+ L: Iterator,
+ R: Iterator
- ,
+ L::Item: Ord,
+ L::Item: PartialEq,
+{
+ left.merge_join_by(right, Ord::cmp)
+}
+
+pub fn show_fs_instr_diff(
+ instr: &FsInstruction,
+ diff_command: &[String],
+ pager_command: &[String],
+) -> Result<(), anyhow::Error> {
+ match &instr.op {
+ FsOp::CreateFile(contents) => {
+ show_file_diff(&instr.path, contents, diff_command, pager_command)?;
+ }
+ FsOp::Remove => {
+ println!(
+ "{}: Would apply action: {}",
+ instr.path,
+ style(&instr.op).red()
+ );
+ }
+ FsOp::CreateDirectory
+ | FsOp::CreateFifo
+ | FsOp::CreateBlockDevice { .. }
+ | FsOp::CreateCharDevice { .. } => {
+ println!(
+ "{}: Would apply action: {}",
+ instr.path,
+ style(&instr.op).green()
+ );
+ }
+ FsOp::CreateSymlink { target } => {
+ // Get old target
+ let old_target = match std::fs::read_link(&instr.path) {
+ Ok(target) => Utf8PathBuf::from_path_buf(target)
+ .map_err(|p| anyhow::anyhow!("Failed to convert path to UTF-8: {:?}", p))?
+ .to_string(),
+ Err(error) => match error.kind() {
+ std::io::ErrorKind::NotFound => "".to_string(),
+ _ => return Err(error.into()),
+ },
+ };
+ // Show diff
+ println!(
+ "{}: Would change symlink target: {} -> {}",
+ instr.path,
+ style(old_target).red(),
+ style(target).green()
+ );
+ }
+ FsOp::SetMode { mode } => {
+ // Get old
+ let old_mode = std::fs::symlink_metadata(&instr.path)
+ .map(|m| m.permissions().mode() & MODE_MASK)
+ .unwrap_or(0);
+ // Show diff
+ println!(
+ "{}: Would change mode: {} -> {}",
+ instr.path,
+ style(format!("{:o}", old_mode)).red(),
+ style(format!("{:o}", mode.as_raw())).green()
+ );
+ }
+ FsOp::SetOwner { owner } => {
+ // Get old UID
+ let old_uid = std::fs::symlink_metadata(&instr.path)
+ .map(|m| m.uid())
+ .unwrap_or(0);
+ // Resolve to old user
+ let old_user = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(old_uid))?
+ .map(|u| u.name)
+ .unwrap_or_else(|| "".to_string());
+ // Resolve new owner to new UID
+ let new_uid = nix::unistd::User::from_name(owner.as_str())?
+ .map(|u| u.uid.as_raw())
+ .map(|uid| format!("{}", uid))
+ .unwrap_or_else(|| "".to_string());
+ // Show diff
+ println!(
+ "{}: Would change owner: {} ({}) -> {} ({})",
+ instr.path,
+ style(old_user).red(),
+ style(old_uid).red(),
+ style(owner).green(),
+ style(new_uid).green()
+ );
+ }
+ FsOp::SetGroup { group } => {
+ // Get old GID
+ let old_gid = std::fs::symlink_metadata(&instr.path)
+ .map(|m| m.gid())
+ .unwrap_or(0);
+ // Resolve to old group
+ let old_group = nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(old_gid))?
+ .map(|g| g.name)
+ .unwrap_or_else(|| "".to_string());
+ // Resolve new group to new GID
+ let new_gid = nix::unistd::Group::from_name(group.as_str())?
+ .map(|g| g.gid.as_raw())
+ .map(|gid| format!("{}", gid))
+ .unwrap_or_else(|| "".to_string());
+ // Show diff
+ println!(
+ "{}: Would change group: {} ({}) -> {} ({})",
+ instr.path,
+ style(old_group).red(),
+ style(old_gid).red(),
+ style(group).green(),
+ style(new_gid).green()
+ );
+ }
+ FsOp::Restore { .. } => {
+ println!(
+ "{}: Would restore to original package manager state",
+ style(&instr.path).color256(202)
+ );
+ }
+ FsOp::Comment => (),
+ };
+ Ok(())
+}
+
+fn show_file_diff(
+ sys_path: &Utf8Path,
+ contents: &konfigkoll_types::FileContents,
+ diff_command: &[String],
+ pager_command: &[String],
+) -> anyhow::Result<()> {
+ let diff = match contents {
+ konfigkoll_types::FileContents::Literal { checksum: _, data } => duct::cmd(
+ &diff_command[0],
+ diff_command[1..]
+ .iter()
+ .chain(&[sys_path.to_string(), "/dev/stdin".into()]),
+ )
+ .stdin_bytes(data.clone()),
+ konfigkoll_types::FileContents::FromFile { checksum: _, path } => duct::cmd(
+ &diff_command[0],
+ diff_command[1..]
+ .iter()
+ .chain(&[sys_path.to_string(), path.to_string()]),
+ ),
+ }
+ .unchecked();
+ let pipeline = diff.pipe(duct::cmd(&pager_command[0], pager_command[1..].iter()));
+ match pipeline.run() {
+ Ok(output) => {
+ if !output.status.success() {
+ tracing::warn!(
+ "Diff or pager exited with non-zero status: {}",
+ output.status
+ );
+ }
+ Ok(())
+ }
+ Err(err) => {
+ tracing::error!(
+ "Diff or pager exited with: {}, kind: {}, OS code {:?}",
+ err,
+ err.kind(),
+ err.raw_os_error()
+ );
+ Ok(())
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_comm() {
+ let left = [1, 2, 3, 4, 5, 8];
+ let right = [3, 4, 5, 6, 7];
+
+ let mut comm_iter = comm(left.into_iter(), right.into_iter());
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Left(1)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Left(2)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Both(3, 3)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Both(4, 4)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Both(5, 5)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Right(6)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Right(7)));
+ assert_eq!(comm_iter.next(), Some(EitherOrBoth::Left(8)));
+ assert_eq!(comm_iter.next(), None);
+ }
+}
diff --git a/crates/konfigkoll_core/src/lib.rs b/crates/konfigkoll_core/src/lib.rs
new file mode 100644
index 00000000..45eeee10
--- /dev/null
+++ b/crates/konfigkoll_core/src/lib.rs
@@ -0,0 +1,11 @@
+//! Core library of konfigkoll
+//!
+//! **Warning**: This is not a stable API for public consumption.
+pub mod apply;
+pub mod confirm;
+pub mod conversion;
+pub mod diff;
+pub mod line_edit;
+pub mod save;
+pub mod state;
+pub mod utils;
diff --git a/crates/konfigkoll_core/src/line_edit.rs b/crates/konfigkoll_core/src/line_edit.rs
new file mode 100644
index 00000000..790d1f66
--- /dev/null
+++ b/crates/konfigkoll_core/src/line_edit.rs
@@ -0,0 +1,677 @@
+//! A simple streaming line editor (inspired by sed, but simplified)
+
+use std::{borrow::Cow, cell::RefCell, fmt::Debug, rc::Rc, str::Lines};
+
+use compact_str::CompactString;
+use regex::Regex;
+
+/// A program consists of a bunch of commands and can be applied to a string line by line.
+///
+/// Like sed the basic algorithm is to repeatedly (until the input is consumed):
+/// 1. Read a line into a "pattern space" buffer
+/// 2. For each instruction in the program:
+/// 1. Check if selector matches the current line number and/or line contents
+/// 2. Apply action on the pattern space
+/// 3. Append the pattern space to the output buffer
+/// 4. Clear the pattern space
+///
+/// This means that the instructions will operate on the pattern space
+/// *as changed by any previous instructions* in the program.
+#[derive(Debug, Clone)]
+pub struct EditProgram {
+ instructions: Vec,
+ print_default: bool,
+}
+
+impl Default for EditProgram {
+ fn default() -> Self {
+ Self {
+ instructions: Default::default(),
+ print_default: true,
+ }
+ }
+}
+
+impl EditProgram {
+ /// Create a new empty program.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Add a new instruction to the program.
+ pub fn add(&mut self, selector: Selector, selector_invert: bool, action: Action) -> &mut Self {
+ self.instructions.push(Instruction {
+ selector,
+ selector_invert,
+ action,
+ });
+ self
+ }
+
+ /// Disable the default implicit action of putting the current pattern space into the output.
+ pub fn disable_default_printing(&mut self) -> &mut Self {
+ self.print_default = false;
+ self
+ }
+
+ /// Helper to implement `NextLine` command
+ fn advance_line<'lines>(
+ &self,
+ pattern_space: &mut String,
+ output: &mut String,
+ line: &mut &'lines str,
+ lines: &mut Lines<'lines>,
+ line_number: &mut usize,
+ ) -> bool {
+ if self.print_default {
+ output.push_str(pattern_space);
+ output.push('\n');
+ }
+ pattern_space.clear();
+ if let Some(line_) = lines.next() {
+ *line = line_;
+ *line_number += 1;
+ pattern_space.push_str(line);
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Apply this program to the given input string.
+ pub fn apply(&self, input: &str) -> String {
+ let mut output = String::new();
+ let mut line_number = 0;
+ let mut pattern_space = String::new();
+ let mut lines = input.lines();
+ 'input: while let Some(line) = lines.next() {
+ line_number += 1;
+ pattern_space.push_str(line);
+
+ let prog_action = self.execute_program(
+ &mut pattern_space,
+ &mut line_number,
+ line,
+ &mut lines,
+ &mut output,
+ );
+ match prog_action {
+ ProgramAction::Done => (),
+ ProgramAction::Stop => break 'input,
+ ProgramAction::StopAndPrint => {
+ print_rest_of_input(&mut output, &mut pattern_space, &mut lines);
+ break 'input;
+ }
+ ProgramAction::ShortCircuit => continue 'input,
+ }
+ if self.print_default {
+ output.push_str(&pattern_space);
+ output.push('\n');
+ }
+ pattern_space.clear();
+ }
+ // Run end of file match:
+ pattern_space.clear();
+ for instr in &self.instructions {
+ if let Selector::Eof = instr.selector {
+ match instr.action.apply(&mut pattern_space, &mut output) {
+ ActionResult::Continue => (),
+ ActionResult::ShortCircuit => break,
+ ActionResult::Stop => break,
+ ActionResult::StopAndPrint => {
+ print_rest_of_input(&mut output, &mut pattern_space, &mut lines);
+ break;
+ }
+ ActionResult::NextLine => {
+ tracing::error!("NextLine not allowed in EOF selector");
+ }
+ ActionResult::Subprogram(_) => todo!(),
+ }
+ }
+ }
+ if !pattern_space.is_empty() {
+ let pattern_space = if let Some(stripped) = pattern_space.strip_prefix('\n') {
+ stripped
+ } else {
+ &pattern_space
+ };
+ output.push_str(pattern_space);
+ if !pattern_space.ends_with('\n') {
+ output.push('\n');
+ }
+ }
+ output
+ }
+
+ fn execute_program<'lines>(
+ &self,
+ pattern_space: &mut String,
+ line_number: &mut usize,
+ mut line: &'lines str,
+ lines: &mut Lines<'lines>,
+ output: &mut String,
+ ) -> ProgramAction {
+ for instr in &self.instructions {
+ if instr.matches(LineNo::Line(*line_number), line) {
+ match instr.action.apply(pattern_space, output) {
+ ActionResult::Continue => (),
+ ActionResult::ShortCircuit => return ProgramAction::ShortCircuit,
+ ActionResult::Stop => return ProgramAction::Stop,
+ ActionResult::StopAndPrint => return ProgramAction::StopAndPrint,
+ ActionResult::NextLine => {
+ self.advance_line(pattern_space, output, &mut line, lines, line_number);
+ }
+ ActionResult::Subprogram(sub) => {
+ let result = sub.borrow().execute_program(
+ pattern_space,
+ line_number,
+ line,
+ lines,
+ output,
+ );
+ match result {
+ ProgramAction::Done => (),
+ ProgramAction::Stop => return ProgramAction::Stop,
+ ProgramAction::StopAndPrint => return ProgramAction::StopAndPrint,
+ // TODO: Is this the sensible semantics?
+ ProgramAction::ShortCircuit => return ProgramAction::ShortCircuit,
+ }
+ }
+ }
+ }
+ }
+ ProgramAction::Done
+ }
+}
+
+fn print_rest_of_input(output: &mut String, pattern_space: &mut String, lines: &mut Lines<'_>) {
+ output.push_str(&*pattern_space);
+ pattern_space.clear();
+ output.push('\n');
+ for line in lines.by_ref() {
+ output.push_str(line);
+ output.push('\n');
+ }
+}
+
+#[derive(Debug)]
+enum ProgramAction {
+ Done,
+ Stop,
+ StopAndPrint,
+ ShortCircuit,
+}
+
+/// An instruction consists of a selector and an action.
+#[derive(Debug, Clone)]
+struct Instruction {
+ selector: Selector,
+ selector_invert: bool,
+ action: Action,
+}
+
+impl Instruction {
+ fn matches(&self, line_no: LineNo, line: &str) -> bool {
+ let matches = self.selector.matches(line_no, line);
+ if self.selector_invert {
+ !matches
+ } else {
+ matches
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum LineNo {
+ Line(usize),
+ Eof,
+}
+
+#[derive(Clone)]
+#[non_exhaustive]
+pub enum Selector {
+ /// Match all lines
+ All,
+ /// End of file (useful to insert lines at the very end)
+ Eof,
+ /// Match a specific line number (1-indexed)
+ Line(usize),
+ /// A range of line numbers (1-indexed, inclusive)
+ Range(usize, usize),
+ /// A regex to match the line
+ Regex(Regex),
+ /// A custom function, passed the line number and current line
+ #[allow(clippy::type_complexity)]
+ Function(Rc bool>),
+}
+
+impl Selector {
+ fn matches(&self, line_no: LineNo, line: &str) -> bool {
+ match self {
+ Selector::All => true,
+ Selector::Eof => line_no == LineNo::Eof,
+ Selector::Line(v) => line_no == LineNo::Line(*v),
+ Selector::Range(l, u) => match line_no {
+ LineNo::Line(line_no) => line_no >= *l && line_no <= *u,
+ _ => false,
+ },
+ Selector::Regex(re) => re.is_match(line),
+ Selector::Function(func) => match line_no {
+ LineNo::Line(line_no) => func(line_no, line),
+ _ => false,
+ },
+ }
+ }
+}
+
+impl Debug for Selector {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::All => write!(f, "All"),
+ Self::Eof => write!(f, "Eof"),
+ Self::Line(arg0) => f.debug_tuple("Line").field(arg0).finish(),
+ Self::Range(arg0, arg1) => f.debug_tuple("Range").field(arg0).field(arg1).finish(),
+ Self::Regex(arg0) => f.debug_tuple("Regex").field(arg0).finish(),
+ Self::Function(_) => f.debug_tuple("Function").finish(),
+ }
+ }
+}
+
+#[derive(Clone)]
+#[non_exhaustive]
+pub enum Action {
+ /// Copy the current line to the output. Only needed when auto-print is disabled.
+ Print,
+ /// Delete the current line and short circuit the rest of the program (immediately go to the next line)
+ Delete,
+ /// Replace pattern space with next line (will print unless auto-print is disabled)
+ NextLine,
+ /// Stop processing the input and program and terminate early (do not print rest of file)
+ Stop,
+ /// Stop processing the input and program and terminate early (auto-print rest of file)
+ StopAndPrint,
+ /// Insert a new line *before* the current line
+ InsertBefore(CompactString),
+ /// Insert a new line *after* the current line
+ InsertAfter(CompactString),
+ /// Replace the entire current string with the given string
+ Replace(CompactString),
+ /// Do a regex search and replace in the current line
+ ///
+ /// Capture groups in the replacement string works as with [`Regex::replace`].
+ RegexReplace {
+ regex: Regex,
+ replacement: CompactString,
+ replace_all: bool,
+ },
+ /// A sub-program that is executed. Will share pattern space with parent program
+ Subprogram(Rc>),
+ /// Call a custom function to determine the new line
+ #[allow(clippy::type_complexity)]
+ Function(Rc Cow<'_, str>>),
+}
+
+impl Action {
+ fn apply(&self, pattern_space: &mut String, output: &mut String) -> ActionResult {
+ match self {
+ Action::Print => {
+ output.push_str(pattern_space);
+ output.push('\n');
+ }
+ Action::Delete => {
+ pattern_space.clear();
+ return ActionResult::ShortCircuit;
+ }
+ Action::Stop => return ActionResult::Stop,
+ Action::StopAndPrint => return ActionResult::StopAndPrint,
+ Action::InsertBefore(s) => {
+ let old_pattern_space = std::mem::take(pattern_space);
+ *pattern_space = s.to_string();
+ pattern_space.push('\n');
+ pattern_space.push_str(&old_pattern_space);
+ }
+ Action::InsertAfter(s) => {
+ pattern_space.push('\n');
+ pattern_space.push_str(s);
+ }
+ Action::Replace(s) => {
+ *pattern_space = s.to_string();
+ }
+ Action::RegexReplace {
+ regex,
+ replacement,
+ replace_all,
+ } => {
+ let ret = if *replace_all {
+ regex.replace_all(pattern_space, replacement.as_str())
+ } else {
+ regex.replace(pattern_space, replacement.as_str())
+ };
+ match ret {
+ Cow::Borrowed(_) => (),
+ Cow::Owned(new_val) => *pattern_space = new_val,
+ };
+ }
+ Action::Function(func) => {
+ let new_val = func(pattern_space);
+ *pattern_space = new_val.into_owned();
+ }
+ Action::NextLine => return ActionResult::NextLine,
+ Action::Subprogram(prog) => return ActionResult::Subprogram(prog.clone()),
+ }
+ ActionResult::Continue
+ }
+}
+
+impl Debug for Action {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Print => write!(f, "Print"),
+ Self::Delete => write!(f, "Delete"),
+ Self::Stop => write!(f, "Stop"),
+ Self::StopAndPrint => write!(f, "StopAndPrint"),
+ Self::InsertBefore(arg0) => f.debug_tuple("InsertBefore").field(arg0).finish(),
+ Self::InsertAfter(arg0) => f.debug_tuple("InsertAfter").field(arg0).finish(),
+ Self::Replace(arg0) => f.debug_tuple("Replace").field(arg0).finish(),
+ Self::RegexReplace {
+ regex,
+ replacement,
+ replace_all,
+ } => f
+ .debug_struct("RegexReplace")
+ .field("regex", regex)
+ .field("replacement", replacement)
+ .field("replace_all", &replace_all)
+ .finish(),
+ Self::Function(_) => f.debug_tuple("Function").finish(),
+ Self::NextLine => write!(f, "LoadNextLine"),
+ Self::Subprogram(arg0) => f.debug_tuple("Subprogram").field(arg0).finish(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+enum ActionResult {
+ Continue,
+ NextLine,
+ Subprogram(Rc>),
+ ShortCircuit,
+ Stop,
+ StopAndPrint,
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+
+ #[test]
+ fn test_regex_replace() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::RegexReplace {
+ regex: Regex::new("^foo$").unwrap(),
+ replacement: "bar".into(),
+ replace_all: false,
+ },
+ );
+ let input = "foo\nbar\nbaz";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbar\nbaz\n");
+ }
+
+ #[test]
+ fn test_regex_replace_no_anchors() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::RegexReplace {
+ regex: Regex::new("foo").unwrap(),
+ replacement: "bar".into(),
+ replace_all: false,
+ },
+ );
+ let input = "foo foo\nbar\nbaz";
+ let output = program.apply(input);
+ assert_eq!(output, "bar foo\nbar\nbaz\n");
+
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::RegexReplace {
+ regex: Regex::new("foo").unwrap(),
+ replacement: "bar".into(),
+ replace_all: true,
+ },
+ );
+ let input = "foo foo\nbar\nbaz";
+ let output = program.apply(input);
+ assert_eq!(output, "bar bar\nbar\nbaz\n");
+ }
+
+ #[test]
+ fn test_regex_replace_capture_groups() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::RegexReplace {
+ regex: Regex::new("f(a|o)o").unwrap(),
+ replacement: "b${1}r".into(),
+ replace_all: true,
+ },
+ );
+ let input = "foo\nfao foo fee\nbar\nbaz";
+ let output = program.apply(input);
+ assert_eq!(output, "bor\nbar bor fee\nbar\nbaz\n");
+
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::RegexReplace {
+ regex: Regex::new("f(a|o)o").unwrap(),
+ replacement: "b${1}r".into(),
+ replace_all: false,
+ },
+ );
+ let input = "foo\nfoo\nfao foo fee\nbar\nbaz";
+ let output = program.apply(input);
+ assert_eq!(output, "bor\nbor\nbar foo fee\nbar\nbaz\n");
+ }
+
+ #[test]
+ fn test_insert_before() {
+ let mut program = EditProgram::new();
+ program.add(Selector::Line(2), false, Action::InsertBefore("foo".into()));
+ let input = "bar\nbaz\nquux";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nfoo\nbaz\nquux\n");
+ }
+
+ #[test]
+ fn test_insert_after() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Regex(Regex::new("^q").unwrap()),
+ false,
+ Action::InsertAfter("foo".into()),
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbaz\nquux\nfoo\nquack\nfoo\n");
+ }
+
+ #[test]
+ fn test_replace() {
+ let mut program = EditProgram::new();
+ program.add(Selector::Range(2, 3), false, Action::Replace("foo".into()));
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nfoo\nfoo\nquack\n");
+
+ // Test inverted selector
+ let mut program = EditProgram::new();
+ program.add(Selector::Range(2, 3), true, Action::Replace("foo".into()));
+ let output = program.apply(input);
+ assert_eq!(output, "foo\nbaz\nquux\nfoo\n");
+ }
+
+ #[test]
+ fn test_function() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::All,
+ false,
+ Action::Function(Rc::new(|line| {
+ if line == "bar" {
+ Cow::Borrowed("baz")
+ } else {
+ Cow::Borrowed(line)
+ }
+ })),
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "baz\nbaz\nquux\nquack\n");
+ }
+
+ #[test]
+ fn test_selector_function() {
+ let mut program = EditProgram::new();
+ program.disable_default_printing();
+ program.add(
+ Selector::Function(Rc::new(|line_no, _line| line_no % 2 == 0)),
+ false,
+ Action::Print,
+ );
+ let input = "bar\nbaz\nquux\nquack\nhuzza\nbar";
+ let output = program.apply(input);
+ assert_eq!(output, "baz\nquack\nbar\n");
+
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Function(Rc::new(|line_no, _line| line_no % 2 == 0)),
+ false,
+ Action::Delete,
+ );
+ let input = "bar\nbaz\nquux\nquack\nhuzza\nbar";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nquux\nhuzza\n");
+ }
+
+ #[test]
+ fn test_delete() {
+ let mut program = EditProgram::new();
+ program.add(Selector::Line(2), false, Action::Delete);
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nquux\nquack\n");
+
+ // Test inverted selector
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Regex(Regex::new("x$").unwrap()),
+ true,
+ Action::Delete,
+ );
+ let output = program.apply(input);
+ assert_eq!(output, "quux\n");
+ }
+
+ #[test]
+ fn test_stop() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Regex(Regex::new("x").unwrap()),
+ false,
+ Action::Stop,
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbaz\n");
+
+ let mut program = EditProgram::new();
+ program.add(Selector::All, false, Action::Replace("foo".into()));
+ program.add(
+ Selector::Regex(Regex::new("x").unwrap()),
+ false,
+ Action::Stop,
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "foo\nfoo\n");
+ }
+
+ #[test]
+ fn test_stop_and_print() {
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Regex(Regex::new("x").unwrap()),
+ false,
+ Action::StopAndPrint,
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbaz\nquux\nquack\n");
+
+ let mut program = EditProgram::new();
+ program.add(Selector::All, false, Action::Replace("foo".into()));
+ program.add(
+ Selector::Regex(Regex::new("x").unwrap()),
+ false,
+ Action::StopAndPrint,
+ );
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "foo\nfoo\nfoo\nquack\n");
+ }
+
+ #[test]
+ fn test_print() {
+ let mut program = EditProgram::new();
+ program.disable_default_printing();
+ program.add(Selector::Range(2, 3), false, Action::Print);
+ program.add(Selector::Range(3, 4), false, Action::Print);
+ let input = "bar\nbaz\nquux\nquack\nhuzza";
+ let output = program.apply(input);
+ assert_eq!(output, "baz\nquux\nquux\nquack\n");
+ }
+
+ #[test]
+ fn test_eof() {
+ let mut program = EditProgram::new();
+ program.add(Selector::Eof, false, Action::InsertBefore("foo".into()));
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbaz\nquux\nquack\nfoo\n");
+
+ let mut program = EditProgram::new();
+ program.add(Selector::Eof, false, Action::InsertAfter("foo".into()));
+ program.add(Selector::Eof, false, Action::InsertAfter("bar".into()));
+ let input = "bar\nbaz\nquux\nquack";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nbaz\nquux\nquack\nfoo\nbar\n");
+ }
+
+ #[test]
+ fn test_subprogram() {
+ let mut subprogram = EditProgram::new();
+ subprogram.add(Selector::All, false, Action::Replace("foo".into()));
+ subprogram.add(Selector::All, false, Action::NextLine);
+ subprogram.add(Selector::All, false, Action::Replace("bar".into()));
+ let mut program = EditProgram::new();
+ program.add(
+ Selector::Regex(Regex::new("quux").unwrap()),
+ false,
+ Action::Subprogram(Rc::new(RefCell::new(subprogram))),
+ );
+ let input = "bar\nquux\nquack\nx\ny";
+ let output = program.apply(input);
+ assert_eq!(output, "bar\nfoo\nbar\nx\ny\n");
+ }
+}
diff --git a/crates/konfigkoll_core/src/save.rs b/crates/konfigkoll_core/src/save.rs
new file mode 100644
index 00000000..b2fbc3b0
--- /dev/null
+++ b/crates/konfigkoll_core/src/save.rs
@@ -0,0 +1,231 @@
+//! Generate a stream of commands that would create the current system state
+
+use anyhow::Context;
+use camino::Utf8Path;
+use compact_str::{format_compact, CompactString};
+use itertools::Itertools;
+use konfigkoll_types::{FileContents, FsInstruction, PkgIdent, PkgInstruction};
+
+/// Save file system changes
+///
+/// Takes a fn that is repsonsible for writing out the file data to a location in the config directory.
+/// It should put the file in the standard location (`files/input_file_path`, e.g `files/etc/fstab`)
+///
+/// Precondition: The instructions are sorted by default sort order (path, op)
+pub fn save_fs_changes<'instruction>(
+ output: &mut dyn std::io::Write,
+ mut file_data_saver: impl FnMut(&Utf8Path, &FileContents) -> anyhow::Result<()>,
+ instructions: impl Iterator
- ,
+) -> anyhow::Result<()> {
+ for instruction in instructions {
+ let comment = match instruction.comment {
+ Some(ref comment) => format_compact!(" // {}", comment),
+ None => CompactString::default(),
+ };
+ match instruction.op {
+ konfigkoll_types::FsOp::Remove => {
+ writeln!(output, " cmds.rm(\"{}\")?;{}", instruction.path, comment)?;
+ }
+ konfigkoll_types::FsOp::CreateFile(ref contents) => {
+ file_data_saver(&instruction.path, contents).with_context(|| {
+ format!("Failed to save {} to config directory", instruction.path)
+ })?;
+ writeln!(
+ output,
+ " cmds.copy(\"{}\")?;{}",
+ instruction.path, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::CreateSymlink { ref target } => {
+ writeln!(
+ output,
+ " cmds.symlink(\"{}\", \"{}\")?;{}",
+ instruction.path, target, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::CreateDirectory => {
+ writeln!(
+ output,
+ " cmds.mkdir(\"{}\")?;{}",
+ instruction.path, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::CreateFifo => {
+ writeln!(
+ output,
+ " cmds.mkfifo(\"{}\")?;{}",
+ instruction.path, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::CreateBlockDevice { major, minor } => {
+ writeln!(
+ output,
+ " cmds.mknod(\"{}\", \"b\", {}, {})?;{}",
+ instruction.path, major, minor, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::CreateCharDevice { major, minor } => {
+ writeln!(
+ output,
+ " cmds.mknod(\"{}\", \"c\", {}, {})?;{}",
+ instruction.path, major, minor, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::SetMode { mode } => {
+ writeln!(
+ output,
+ " cmds.chmod(\"{}\", 0o{:o})?;{}",
+ instruction.path,
+ mode.as_raw(),
+ comment
+ )?;
+ }
+ konfigkoll_types::FsOp::SetOwner { ref owner } => {
+ writeln!(
+ output,
+ " cmds.chown(\"{}\", \"{}\")?;{}",
+ instruction.path, owner, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::SetGroup { ref group } => {
+ writeln!(
+ output,
+ " cmds.chgrp(\"{}\", \"{}\")?;{}",
+ instruction.path, group, comment
+ )?;
+ }
+ konfigkoll_types::FsOp::Comment => {
+ writeln!(output, " // {}: {}", instruction.path, comment)?;
+ }
+ konfigkoll_types::FsOp::Restore { .. } => {
+ writeln!(
+ output,
+ " restore({}) // Restore this file to original package manager state{}",
+ instruction.path, comment
+ )?;
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Save package changes
+pub fn save_packages<'instructions>(
+ output: &mut dyn std::io::Write,
+ instructions: impl Iterator
- ,
+) -> anyhow::Result<()> {
+ let instructions = instructions
+ .into_iter()
+ .sorted_unstable_by(|(ak, av), (bk, bv)| {
+ av.op
+ .cmp(&bv.op)
+ .then_with(|| ak.package_manager.cmp(&bk.package_manager))
+ .then_with(|| ak.identifier.cmp(&bk.identifier))
+ });
+
+ for (pkg_ident, pkg_instruction) in instructions.into_iter() {
+ let comment = match &pkg_instruction.comment {
+ Some(comment) => format_compact!(" // {}", comment),
+ None => CompactString::default(),
+ };
+ match pkg_instruction.op {
+ konfigkoll_types::PkgOp::Uninstall => {
+ writeln!(
+ output,
+ " cmds.remove_pkg(\"{}\", \"{}\")?;{}",
+ pkg_ident.package_manager, pkg_ident.identifier, comment
+ )?;
+ }
+ konfigkoll_types::PkgOp::Install => {
+ writeln!(
+ output,
+ " cmds.add_pkg(\"{}\", \"{}\")?;{}",
+ pkg_ident.package_manager, pkg_ident.identifier, comment
+ )?;
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use paketkoll_types::backend::Backend;
+ use pretty_assertions::assert_eq;
+ use std::collections::HashMap;
+
+ use camino::{Utf8Path, Utf8PathBuf};
+ use konfigkoll_types::{
+ FileContents, FsInstruction, FsOp, PkgIdent, PkgInstruction, PkgInstructions, PkgOp,
+ };
+
+ use super::*;
+
+ #[test]
+ fn test_save_fs_changes() {
+ let mut output = Vec::new();
+ let mut file_data = HashMap::new();
+ let file_data_saver = |path: &Utf8Path, contents: &FileContents| {
+ file_data.insert(path.to_owned(), contents.clone());
+ Ok(())
+ };
+
+ let instructions = vec![
+ FsInstruction {
+ op: FsOp::CreateFile(FileContents::from_literal("hello".as_bytes().into())),
+ path: Utf8PathBuf::from("/hello/world"),
+ comment: None,
+ },
+ FsInstruction {
+ op: FsOp::Remove,
+ path: Utf8PathBuf::from("/remove_me"),
+ comment: Some("For reasons!".into()),
+ },
+ ];
+
+ save_fs_changes(&mut output, file_data_saver, instructions.iter()).unwrap();
+
+ let expected =
+ " cmds.copy(\"/hello/world\")?;\n cmds.rm(\"/remove_me\")?; // For reasons!\n";
+ assert_eq!(String::from_utf8(output).unwrap(), expected);
+ assert_eq!(
+ file_data.get(Utf8Path::new("/hello/world")).unwrap(),
+ &FileContents::from_literal("hello".as_bytes().into())
+ );
+ }
+
+ #[test]
+ fn test_save_packages() {
+ let mut output = Vec::new();
+ let mut instructions = PkgInstructions::default();
+ instructions.insert(
+ PkgIdent {
+ package_manager: Backend::Pacman,
+ identifier: "bash".into(),
+ },
+ PkgInstruction {
+ op: PkgOp::Install,
+ comment: None,
+ },
+ );
+ instructions.insert(
+ PkgIdent {
+ package_manager: Backend::Apt,
+ identifier: "zsh".into(),
+ },
+ PkgInstruction {
+ op: PkgOp::Uninstall,
+ comment: Some("A comment".into()),
+ },
+ );
+
+ save_packages(
+ &mut output,
+ instructions.iter().map(|(a, b)| (a, b.clone())).sorted(),
+ )
+ .unwrap();
+
+ let expected = " cmds.remove_pkg(\"apt\", \"zsh\")?; // A comment\n cmds.add_pkg(\"pacman\", \"bash\")?;\n";
+ assert_eq!(String::from_utf8(output).unwrap(), expected);
+ }
+}
diff --git a/crates/konfigkoll_core/src/state.rs b/crates/konfigkoll_core/src/state.rs
new file mode 100644
index 00000000..847f9467
--- /dev/null
+++ b/crates/konfigkoll_core/src/state.rs
@@ -0,0 +1,693 @@
+//! State representation of file system
+
+use std::{collections::BTreeMap, sync::Arc};
+
+use anyhow::anyhow;
+use camino::{Utf8Path, Utf8PathBuf};
+use compact_str::CompactString;
+use konfigkoll_types::{FileContents, FsInstruction, FsOp};
+use paketkoll_types::{
+ backend::Files,
+ files::{Mode, PathMap, Properties},
+};
+
+use crate::utils::{IdKey, NumericToNameResolveCache};
+
+const DEFAULT_FILE_MODE: Mode = Mode::new(0o644);
+const DEFAULT_DIR_MODE: Mode = Mode::new(0o755);
+const ROOT: CompactString = CompactString::const_new("root");
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+struct FsNode {
+ entry: FsEntry,
+ mode: Option,
+ owner: Option,
+ group: Option,
+ /// Keep track of if this node was removed before being added back.
+ /// Needed for handling type conflicts correctly.
+ removed_before_added: bool,
+ /// Optional comment for saving purposes
+ comment: Option,
+}
+
+// This is a macro due to partial moving of self
+macro_rules! fsnode_into_base_instruction {
+ ($this:ident, $path:tt) => {
+ match $this.entry {
+ FsEntry::Removed => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::Remove,
+ comment: $this.comment,
+ }),
+ FsEntry::Unchanged => None,
+ FsEntry::Directory => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateDirectory,
+ comment: $this.comment,
+ }),
+ FsEntry::File(contents) => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateFile(contents),
+ comment: $this.comment,
+ }),
+ FsEntry::Symlink { target } => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateSymlink { target },
+ comment: $this.comment,
+ }),
+ FsEntry::Fifo => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateFifo,
+ comment: $this.comment,
+ }),
+ FsEntry::BlockDevice { major, minor } => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateBlockDevice { major, minor },
+ comment: $this.comment,
+ }),
+ FsEntry::CharDevice { major, minor } => Some(FsInstruction {
+ path: $path.into(),
+ op: FsOp::CreateCharDevice { major, minor },
+ comment: $this.comment,
+ }),
+ }
+ };
+}
+
+impl FsNode {
+ fn into_instruction(self, path: &Utf8Path) -> impl Iterator
- {
+ let mut results = vec![];
+ let mut do_metadata = true;
+ let mut was_symlink = false;
+ let default_mode = match &self.entry {
+ FsEntry::Removed => None,
+ FsEntry::Unchanged => None,
+ FsEntry::Directory => Some(DEFAULT_DIR_MODE),
+ FsEntry::File(_) => Some(DEFAULT_FILE_MODE),
+ FsEntry::Symlink { .. } => None,
+ FsEntry::Fifo | FsEntry::BlockDevice { .. } | FsEntry::CharDevice { .. } => {
+ Some(DEFAULT_FILE_MODE)
+ }
+ };
+
+ if self.removed_before_added && self.entry != FsEntry::Removed {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::Remove,
+ comment: Some("Removed (and later recreated) due to file type conflict".into()),
+ });
+ }
+ match &self.entry {
+ FsEntry::Removed => {
+ do_metadata = false;
+ }
+ FsEntry::Symlink { .. } => {
+ was_symlink = true;
+ }
+ _ => (),
+ }
+ if let Some(instr) = fsnode_into_base_instruction!(self, path) {
+ results.push(instr);
+ }
+
+ if do_metadata {
+ if !was_symlink && self.mode != default_mode {
+ if let Some(mode) = self.mode {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetMode { mode },
+ comment: None,
+ });
+ }
+ }
+ if let Some(owner) = self.owner {
+ if owner != ROOT {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetOwner { owner },
+ comment: None,
+ });
+ }
+ }
+ if let Some(group) = self.group {
+ if group != ROOT {
+ results.push(FsInstruction {
+ path: path.into(),
+ op: FsOp::SetGroup { group },
+ comment: None,
+ });
+ }
+ }
+ }
+
+ results.into_iter()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, strum::EnumDiscriminants)]
+enum FsEntry {
+ /// Negative entry: This has been removed
+ Removed,
+ /// Unchanged, we only got a mode/owner/group change
+ Unchanged,
+ /// A directory
+ Directory,
+ /// A file
+ File(FileContents),
+ /// A symlink
+ Symlink { target: camino::Utf8PathBuf },
+ /// Create a FIFO
+ Fifo,
+ /// Create a block device
+ BlockDevice { major: u64, minor: u64 },
+ /// Create a character device
+ CharDevice { major: u64, minor: u64 },
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct FsEntries {
+ fs: BTreeMap,
+}
+
+impl FsEntries {
+ /// Apply a stream of instructions to this `FsEntries`
+ pub fn apply_instructions(
+ &mut self,
+ instructions: impl Iterator
- ,
+ warn_redundant: bool,
+ ) {
+ for instr in instructions {
+ match instr.op {
+ FsOp::Remove => {
+ self.fs.insert(
+ instr.path,
+ FsNode {
+ entry: FsEntry::Removed,
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: true,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateDirectory => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::Directory,
+ mode: Some(DEFAULT_DIR_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateFile(contents) => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::File(contents),
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateSymlink { target } => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::Symlink { target },
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateFifo => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::Fifo,
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateBlockDevice { major, minor } => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::BlockDevice { major, minor },
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::CreateCharDevice { major, minor } => {
+ self.replace_node(
+ instr.path,
+ FsNode {
+ entry: FsEntry::CharDevice { major, minor },
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: instr.comment,
+ },
+ );
+ }
+ FsOp::SetMode { mode } => {
+ self.fs
+ .entry(instr.path.clone())
+ .and_modify(|entry| {
+ if warn_redundant && entry.mode == Some(mode) {
+ tracing::warn!("Redundant mode set for: {:?}", &instr.path);
+ }
+ entry.mode = Some(mode);
+ })
+ .or_insert_with(|| FsNode {
+ entry: FsEntry::Unchanged,
+ mode: Some(mode),
+ owner: None,
+ group: None,
+ removed_before_added: false,
+ comment: instr.comment,
+ });
+ }
+ FsOp::SetOwner { ref owner } => {
+ self.fs
+ .entry(instr.path.clone())
+ .and_modify(|entry| {
+ if warn_redundant && entry.owner.as_ref() == Some(owner) {
+ tracing::warn!("Redundant owner set for: {:?}", &instr.path);
+ }
+ entry.owner = Some(owner.clone());
+ })
+ .or_insert_with(|| FsNode {
+ entry: FsEntry::Unchanged,
+ mode: None,
+ owner: Some(owner.clone()),
+ group: None,
+ removed_before_added: false,
+ comment: instr.comment,
+ });
+ }
+ FsOp::SetGroup { ref group } => {
+ self.fs
+ .entry(instr.path.clone())
+ .and_modify(|entry| {
+ if warn_redundant && entry.group.as_ref() == Some(group) {
+ tracing::warn!("Redundant group set for: {:?}", &instr.path);
+ }
+ entry.group = Some(group.clone());
+ })
+ .or_insert_with(|| FsNode {
+ entry: FsEntry::Unchanged,
+ mode: None,
+ owner: None,
+ group: Some(group.clone()),
+ removed_before_added: false,
+ comment: instr.comment,
+ });
+ }
+ FsOp::Comment => (),
+ FsOp::Restore { .. } => {
+ tracing::error!(
+ "Restore operation not supported as *input* to state::apply_instructions"
+ );
+ }
+ }
+ }
+ }
+
+ /// Replace a node, taking into account if it was removed before being added back.
+ fn replace_node(&mut self, path: Utf8PathBuf, new_node: FsNode) {
+ self.add_missing_parents(&path);
+ let entry = self.fs.entry(path).or_insert(FsNode {
+ entry: FsEntry::Removed,
+ mode: Some(Mode::new(0)),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: None,
+ });
+ entry.entry = new_node.entry;
+ entry.mode = new_node.mode;
+ entry.owner = new_node.owner;
+ entry.group = new_node.group;
+ entry.comment = new_node.comment;
+ }
+
+ /// Add missing directory parents for a given node
+ fn add_missing_parents(&mut self, path: &Utf8Path) {
+ for parent in path.ancestors() {
+ self.fs.entry(parent.into()).or_insert_with(|| FsNode {
+ entry: FsEntry::Directory,
+ mode: Some(DEFAULT_DIR_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: None,
+ });
+ }
+ }
+}
+
+/// Describe the goal of the diff: is it for saving or for application/diff
+///
+/// This will affect the exact instructions that gets generated
+#[derive(Debug, Clone, strum::EnumDiscriminants)]
+pub enum DiffGoal<'map, 'files> {
+ Apply(Arc, &'map PathMap<'files>),
+ Save,
+}
+
+impl PartialEq for DiffGoal<'_, '_> {
+ fn eq(&self, other: &Self) -> bool {
+ #[allow(clippy::match_like_matches_macro)]
+ match (self, other) {
+ (DiffGoal::Apply(_, _), DiffGoal::Apply(_, _)) => true,
+ (DiffGoal::Save, DiffGoal::Save) => true,
+ _ => false,
+ }
+ }
+}
+
+// Generate a stream of instructions to go from state before to state after
+#[tracing::instrument(level = "debug", skip_all)]
+pub fn diff(
+ goal: &DiffGoal<'_, '_>,
+ before: FsEntries,
+ after: FsEntries,
+) -> anyhow::Result> {
+ let diff_iter = itertools::merge_join_by(before.fs, after.fs, |(k1, _), (k2, _)| k1.cmp(k2));
+
+ let mut results = vec![];
+
+ let mut id_resolver = NumericToNameResolveCache::new();
+
+ for entry in diff_iter {
+ match entry {
+ itertools::EitherOrBoth::Both(before, after) if before.1 == after.1 => {}
+ itertools::EitherOrBoth::Both(before, after) => {
+ // tracing::debug!("{:?} -> {:?}", before, after);
+ // Compare the structs and generate a stream of instructions
+ let path = before.0;
+ let before = before.1;
+ let after = after.1;
+
+ if before.entry != after.entry {
+ let before_discr = FsEntryDiscriminants::from(&before.entry);
+ let after_discr = FsEntryDiscriminants::from(&after.entry);
+
+ if before.removed_before_added || before_discr != after_discr {
+ // The entry was removed before being added back, generate a removal
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::Remove,
+ comment: Some(
+ "Removed (and later recreated) due to file type conflict".into(),
+ ),
+ });
+ }
+ // Just the properties of it has changed
+ let path = path.as_path();
+ if let Some(instr) = fsnode_into_base_instruction!(after, path) {
+ results.push(instr);
+ }
+ }
+
+ match (before.mode, after.mode) {
+ (None, None) => (),
+ (Some(_), None) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::Comment,
+ comment: Some("Mode change unneeded".into()),
+ });
+ }
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (None, Some(v)) | (Some(_), Some(v)) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::SetMode { mode: v },
+ comment: None,
+ });
+ }
+ }
+ match (before.owner, after.owner) {
+ (None, None) => (),
+ (Some(_), None) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::Comment,
+ comment: Some("Owner change unneeded".into()),
+ });
+ }
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (None, Some(v)) | (Some(_), Some(v)) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::SetOwner { owner: v },
+ comment: None,
+ });
+ }
+ }
+ match (before.group, after.group) {
+ (None, None) => (),
+ (Some(_), None) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::Comment,
+ comment: Some("Group change unneeded".into()),
+ });
+ }
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (None, Some(v)) | (Some(_), Some(v)) => {
+ results.push(FsInstruction {
+ path: path.clone(),
+ op: FsOp::SetGroup { group: v },
+ comment: None,
+ });
+ }
+ }
+ }
+ itertools::EitherOrBoth::Left(before) => {
+ // tracing::debug!("{:?} -> ()", before);
+ match goal {
+ DiffGoal::Apply(ref _backend_impl, path_map) => {
+ // Figure out what the previous state of this file was:
+ match path_map.get(before.0.as_std_path()) {
+ Some(entry) => {
+ if before.1.entry != FsEntry::Unchanged {
+ match entry.properties {
+ Properties::RegularFileBasic(_)
+ | Properties::RegularFileSystemd(_)
+ | Properties::RegularFile(_) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::Restore,
+ comment: before.1.comment,
+ });
+ }
+ Properties::Symlink(ref v) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::CreateSymlink {
+ target: Utf8Path::from_path(&v.target)
+ .ok_or_else(|| anyhow!("Invalid UTF-8"))?
+ .into(),
+ },
+ comment: before.1.comment,
+ });
+ }
+ Properties::Directory(_) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::CreateDirectory,
+ comment: before.1.comment,
+ });
+ }
+ Properties::Fifo(_)
+ | Properties::DeviceNode(_)
+ | Properties::Permissions(_)
+ | Properties::Special
+ | Properties::Removed => {
+ anyhow::bail!("{:?} needs to be restored to package manager state, but how do to that is not yet implemented", entry.path)
+ }
+ Properties::Unknown => {
+ anyhow::bail!("{:?} needs to be restored to package manager state, but how do to that is unknown", entry.path)
+ }
+ }
+ }
+ match (entry.properties.mode(), before.1.mode) {
+ (None, None) | (None, Some(_)) | (Some(_), None) => (),
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (Some(v1), Some(_)) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::SetMode { mode: v1 },
+ comment: None,
+ });
+ }
+ }
+ let fs_owner = entry
+ .properties
+ .owner()
+ .map(|v| id_resolver.lookup(&IdKey::User(v)))
+ .transpose()?;
+ match (fs_owner, before.1.owner) {
+ (None, None) | (None, Some(_)) | (Some(_), None) => (),
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (Some(v1), Some(_)) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::SetOwner { owner: v1 },
+ comment: None,
+ });
+ }
+ }
+ let fs_group = entry
+ .properties
+ .group()
+ .map(|v| id_resolver.lookup(&IdKey::Group(v)))
+ .transpose()?;
+ match (fs_group, before.1.group) {
+ (None, None) | (None, Some(_)) | (Some(_), None) => (),
+ (Some(v1), Some(v2)) if v1 == v2 => (),
+ (Some(v1), Some(_)) => {
+ results.push(FsInstruction {
+ path: before.0.clone(),
+ op: FsOp::SetGroup { group: v1 },
+ comment: None,
+ });
+ }
+ }
+ }
+ None => {
+ results.push(FsInstruction {
+ path: before.0,
+ op: FsOp::Remove,
+ comment: before.1.comment,
+ });
+ }
+ }
+ }
+ DiffGoal::Save => {
+ // Generate instructions to remove the entry
+ results.push(FsInstruction {
+ path: before.0,
+ op: FsOp::Remove,
+ comment: before.1.comment,
+ });
+ // TODO: Do something special when the before instruction is a removal one?I
+ }
+ }
+ }
+ itertools::EitherOrBoth::Right(after) => {
+ // tracing::debug!("() -> {:?}", after);
+ results.extend(after.1.into_instruction(&after.0));
+ }
+ }
+ }
+
+ Ok(results.into_iter())
+}
+
+#[cfg(test)]
+mod tests {
+ use FsOp;
+
+ use super::*;
+
+ #[test]
+ fn test_apply_instructions() {
+ let mut entries = FsEntries::default();
+ let instrs = vec![
+ FsInstruction {
+ path: "/hello/symlink".into(),
+ op: FsOp::CreateSymlink {
+ target: "/hello/target".into(),
+ },
+ comment: None,
+ },
+ FsInstruction {
+ path: "/hello/file".into(),
+ op: FsOp::CreateFile(FileContents::from_literal(
+ b"hello".to_vec().into_boxed_slice(),
+ )),
+ comment: Some("A comment".into()),
+ },
+ FsInstruction {
+ path: "/hello/file".into(),
+ op: FsOp::SetMode {
+ mode: Mode::new(0o600),
+ },
+ comment: None,
+ },
+ ];
+ entries.apply_instructions(instrs.into_iter(), false);
+
+ assert_eq!(
+ entries.fs.get(Utf8Path::new("/hello/symlink")),
+ Some(&FsNode {
+ entry: FsEntry::Symlink {
+ target: "/hello/target".into()
+ },
+ mode: Some(DEFAULT_FILE_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: None,
+ })
+ );
+ assert_eq!(
+ entries.fs.get(Utf8Path::new("/hello/file")),
+ Some(&FsNode {
+ entry: FsEntry::File(FileContents::from_literal(
+ b"hello".to_vec().into_boxed_slice()
+ )),
+ mode: Some(Mode::new(0o600)),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: Some("A comment".into()),
+ })
+ );
+ assert_eq!(
+ entries.fs.get(Utf8Path::new("/hello")),
+ Some(&FsNode {
+ entry: FsEntry::Directory,
+ mode: Some(DEFAULT_DIR_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: None,
+ })
+ );
+ assert_eq!(
+ entries.fs.get(Utf8Path::new("/")),
+ Some(&FsNode {
+ entry: FsEntry::Directory,
+ mode: Some(DEFAULT_DIR_MODE),
+ owner: Some(ROOT),
+ group: Some(ROOT),
+ removed_before_added: false,
+ comment: None,
+ })
+ );
+ }
+}
diff --git a/crates/konfigkoll_core/src/utils.rs b/crates/konfigkoll_core/src/utils.rs
new file mode 100644
index 00000000..b2b87a98
--- /dev/null
+++ b/crates/konfigkoll_core/src/utils.rs
@@ -0,0 +1,137 @@
+//! Utilities
+
+use std::num::NonZeroUsize;
+
+use anyhow::anyhow;
+use camino::{Utf8Path, Utf8PathBuf};
+use clru::CLruCache;
+use compact_str::CompactString;
+use paketkoll_types::files::{Gid, Uid};
+
+/// UID/GID to name resolver / cache
+#[derive(Debug)]
+pub(crate) struct IdResolveCache {
+ cache: CLruCache,
+}
+
+impl IdResolveCache
+where
+ Key: PartialEq + Eq + std::hash::Hash,
+{
+ /// Create a new instance
+ pub(crate) fn new() -> Self {
+ Self {
+ cache: CLruCache::with_hasher(
+ NonZeroUsize::new(100).expect("Compile time constant"),
+ ahash::RandomState::new(),
+ ),
+ }
+ }
+}
+
+impl Default for IdResolveCache
+where
+ Key: PartialEq + Eq + std::hash::Hash,
+{
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+pub(crate) type IdKeyId = IdKey;
+pub(crate) type IdKeyName = IdKey;
+
+pub(crate) type NumericToNameResolveCache = IdResolveCache;
+pub(crate) type NameToNumericResolveCache = IdResolveCache;
+
+impl IdResolveCache, CompactString> {
+ /// Lookup a UID/GID (resolving and caching if necessary)
+ pub(crate) fn lookup(&mut self, key: &IdKey) -> anyhow::Result {
+ match self.cache.get(key) {
+ Some(v) => Ok(v.clone()),
+ None => {
+ // Resolve
+ let name: CompactString = match key {
+ IdKey::User(uid) => {
+ nix::unistd::User::from_uid(uid.into())?
+ .ok_or_else(|| anyhow!("Failed to find user with ID {}", uid))?
+ .name
+ }
+ IdKey::Group(gid) => {
+ nix::unistd::Group::from_gid(gid.into())?
+ .ok_or_else(|| anyhow!("Failed to find group with ID {}", gid))?
+ .name
+ }
+ }
+ .into();
+ self.cache.put(*key, name.clone());
+ Ok(name)
+ }
+ }
+ }
+}
+
+impl IdResolveCache, u32> {
+ /// Lookup a UID/GID (resolving and caching if necessary)
+ pub(crate) fn lookup(
+ &mut self,
+ key: &IdKey,
+ ) -> anyhow::Result {
+ match self.cache.get(key) {
+ Some(v) => Ok(*v),
+ None => {
+ // Resolve
+ let id = match key {
+ IdKey::User(user) => nix::unistd::User::from_name(user.as_str())?
+ .ok_or_else(|| anyhow!("Failed to find user with ID {}", user))?
+ .uid
+ .as_raw(),
+ IdKey::Group(group) => nix::unistd::Group::from_name(group.as_str())?
+ .ok_or_else(|| anyhow!("Failed to find group with ID {}", group))?
+ .gid
+ .as_raw(),
+ };
+ self.cache.put(key.clone(), id);
+ Ok(id)
+ }
+ }
+ }
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum IdKey
+where
+ UserKey: Clone + std::fmt::Debug + PartialEq + Eq + std::hash::Hash,
+ GroupKey: Clone + std::fmt::Debug + PartialEq + Eq + std::hash::Hash,
+{
+ User(UserKey),
+ Group(GroupKey),
+}
+
+/// Safe path join that does not replace when the second path is absolute
+pub fn safe_path_join(left: &Utf8Path, right: &Utf8Path) -> Utf8PathBuf {
+ let right = if right.is_absolute() {
+ right
+ .strip_prefix("/")
+ .expect("We know the path is aboslute")
+ } else {
+ right
+ };
+ left.join(right)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_safe_path_join() {
+ assert_eq!(
+ safe_path_join(Utf8Path::new("/a/b"), Utf8Path::new("c/d")),
+ Utf8PathBuf::from("/a/b/c/d")
+ );
+ assert_eq!(
+ safe_path_join(Utf8Path::new("/a/b"), Utf8Path::new("/c/d")),
+ Utf8PathBuf::from("/a/b/c/d")
+ );
+ }
+}
diff --git a/crates/konfigkoll_hwinfo/Cargo.toml b/crates/konfigkoll_hwinfo/Cargo.toml
new file mode 100644
index 00000000..a43cd909
--- /dev/null
+++ b/crates/konfigkoll_hwinfo/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+description = "Hardware info provider for Konfigkoll"
+edition = "2021"
+license = "MPL-2.0"
+name = "konfigkoll_hwinfo"
+repository = "https://github.com/VorpalBlade/paketkoll"
+rust-version = "1.79.0"
+version = "0.1.0"
+
+[dependencies]
+ahash.workspace = true
+anyhow.workspace = true
+itertools.workspace = true
+rune = { workspace = true, optional = true }
+winnow = { workspace = true, features = ["simd"] }
+
+[lints]
+workspace = true
+
+[dev-dependencies]
+indoc.workspace = true
+pretty_assertions = { workspace = true }
+
+[features]
+rune = ["dep:rune"]
diff --git a/crates/konfigkoll_hwinfo/README.md b/crates/konfigkoll_hwinfo/README.md
new file mode 100644
index 00000000..13267a25
--- /dev/null
+++ b/crates/konfigkoll_hwinfo/README.md
@@ -0,0 +1,15 @@
+# konfigkoll_hwinfo
+
+Hardware information module for `KonfigKoll`
+
+This is a collection of functions that no other library seemed to provide.
+You are free to use this, and this follows semver, but it isn't primarily
+intended for third party consumption.
+
+Everything here is Linux only and should work without root access.
+
+## MSRV (Minimum Supported Rust Version) policy
+
+The MSRV may be bumped as needed. It is guaranteed that this library will at
+least build on the current stable Rust release. An MSRV change is not considered
+a breaking change and as such may change even in a patch version.
diff --git a/crates/konfigkoll_hwinfo/src/lib.rs b/crates/konfigkoll_hwinfo/src/lib.rs
new file mode 100644
index 00000000..cd521b50
--- /dev/null
+++ b/crates/konfigkoll_hwinfo/src/lib.rs
@@ -0,0 +1,9 @@
+//! Hardware information module for `KonfigKoll`
+//!
+//! This is a collection of functions that no other library seemed to provide.
+//! You are free to use this, and this follows semver, but it isn't primarily
+//! intended for third party consumption.
+//!
+//! Everything here is Linux only and should work without root access.
+
+pub mod pci;
diff --git a/crates/konfigkoll_hwinfo/src/pci.rs b/crates/konfigkoll_hwinfo/src/pci.rs
new file mode 100644
index 00000000..4636440f
--- /dev/null
+++ b/crates/konfigkoll_hwinfo/src/pci.rs
@@ -0,0 +1,198 @@
+//! Utilities similar to pciutils to read PCI devices on Linux
+
+use ahash::AHashMap;
+
+mod parser;
+
+/// A database of PCI devices IDs
+#[derive(Debug, PartialEq, Eq)]
+#[cfg_attr(feature = "rune", derive(rune::Any))]
+#[cfg_attr(feature = "rune", rune(item = ::sysinfo))]
+pub struct PciIdDb {
+ pub classes: AHashMap,
+ pub vendors: AHashMap,
+}
+
+impl PciIdDb {
+ /// Create from a string containing `pci.ids`
+ pub fn parse(s: &str) -> anyhow::Result {
+ parser::parse_pcidatabase(s)
+ }
+
+ /// Create from a file containing `pci.ids`
+ pub fn parse_file(path: &std::path::Path) -> anyhow::Result {
+ let s = std::fs::read_to_string(path)?;
+ Self::parse(&s)
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Class {
+ pub name: String,
+ pub subclasses: AHashMap,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Subclass {
+ pub name: String,
+ pub program_interfaces: AHashMap,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ProgrammingInterface {
+ pub name: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Vendor {
+ pub name: String,
+ pub devices: AHashMap,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Device {
+ pub name: String,
+ pub subsystems: AHashMap<(u16, u16), Subsystem>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Subsystem {
+ pub name: String,
+}
+
+/// Data about a PCI device
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "rune", derive(rune::Any))]
+#[cfg_attr(feature = "rune", rune(item = ::sysinfo))]
+pub struct PciDevice {
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub class: u32,
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub vendor: u16,
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub device: u16,
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub revision: u8,
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub subsystem_vendor: u16,
+ #[cfg_attr(feature = "rune", rune(get))]
+ pub subsystem_device: u16,
+}
+
+impl PciDevice {
+ /// Load data from /sys
+ fn from_directory(path: &std::path::Path) -> anyhow::Result {
+ let class = std::fs::read_to_string(path.join("class"))?;
+ let vendor = std::fs::read_to_string(path.join("vendor"))?;
+ let device = std::fs::read_to_string(path.join("device"))?;
+ let revision = std::fs::read_to_string(path.join("revision"))?;
+ let subsystem_vendor = std::fs::read_to_string(path.join("subsystem_vendor"))?;
+ let subsystem_device = std::fs::read_to_string(path.join("subsystem_device"))?;
+ Ok(Self {
+ class: u32::from_str_radix(&class, 16)?,
+ vendor: u16::from_str_radix(&vendor, 16)?,
+ device: u16::from_str_radix(&device, 16)?,
+ revision: u8::from_str_radix(&revision, 16)?,
+ subsystem_vendor: u16::from_str_radix(&subsystem_vendor, 16)?,
+ subsystem_device: u16::from_str_radix(&subsystem_device, 16)?,
+ })
+ }
+
+ /// Get the vendor, device and possibly subsystem names
+ pub fn vendor_names<'db>(&self, db: &'db PciIdDb) -> PciVendorLookup<&'db str> {
+ // Resolve vendor
+ let vendor = db.vendors.get(&self.vendor);
+ let device = vendor.and_then(|v| v.devices.get(&self.device));
+ let subsystem = device.and_then(|d| {
+ d.subsystems
+ .get(&(self.subsystem_vendor, self.subsystem_device))
+ });
+ // The subvendor can be different than the main vendor
+ // See https://admin.pci-ids.ucw.cz/mods/PC/?action=help?help=pci
+ let subvendor = db.vendors.get(&self.subsystem_vendor);
+
+ // Extract strings
+ PciVendorLookup {
+ vendor: vendor.map(|v| v.name.as_str()),
+ device: device.map(|d| d.name.as_str()),
+ subvendor: subvendor.map(|v| v.name.as_str()),
+ subdevice: subsystem.map(|s| s.name.as_str()),
+ }
+ }
+
+ /// Get the class, subclass and program interface names
+ pub fn class_strings<'db>(&self, db: &'db PciIdDb) -> PciClassLookup<&'db str> {
+ // Split up class 0xccsspp
+ let class = (self.class >> 16) as u8;
+ let subclass = (self.class >> 8) as u8;
+ let program_interface = self.class as u8;
+
+ // Resolve hierarchy
+ let class = db.classes.get(&class);
+ let subclass = class.and_then(|c| c.subclasses.get(&subclass));
+ let program_interface = subclass.and_then(|s| s.program_interfaces.get(&program_interface));
+
+ // Extract strings
+ PciClassLookup {
+ class: class.map(|c| c.name.as_str()),
+ subclass: subclass.map(|s| s.name.as_str()),
+ program_interface: program_interface.map(|p| p.name.as_str()),
+ }
+ }
+}
+
+/// Result from [`PciDevice::vendor_names`]
+#[derive(Debug, PartialEq, Eq, Hash)]
+pub struct PciVendorLookup
{
+ pub vendor: Option,
+ pub device: Option,
+ pub subvendor: Option,
+ pub subdevice: Option,
+}
+
+impl PciVendorLookup
+where
+ S: ToOwned,
+{
+ pub fn to_owned(&self) -> PciVendorLookup {
+ PciVendorLookup {
+ vendor: self.vendor.as_ref().map(ToOwned::to_owned),
+ device: self.device.as_ref().map(ToOwned::to_owned),
+ subvendor: self.subvendor.as_ref().map(ToOwned::to_owned),
+ subdevice: self.subdevice.as_ref().map(ToOwned::to_owned),
+ }
+ }
+}
+
+/// Result from [`PciDevice::class_strings`]
+#[derive(Debug, PartialEq, Eq, Hash)]
+pub struct PciClassLookup {
+ pub class: Option,
+ pub subclass: Option,
+ pub program_interface: Option,
+}
+
+impl PciClassLookup
+where
+ S: ToOwned,
+{
+ pub fn to_owned(&self) -> PciClassLookup {
+ PciClassLookup {
+ class: self.class.as_ref().map(ToOwned::to_owned),
+ subclass: self.subclass.as_ref().map(ToOwned::to_owned),
+ program_interface: self.program_interface.as_ref().map(ToOwned::to_owned),
+ }
+ }
+}
+
+/// Read PCI device info from `/sys`
+pub fn load_pci_devices() -> anyhow::Result> {
+ let path = std::path::Path::new("/sys/bus/pci/devices");
+ let mut devices = vec![];
+ for entry in std::fs::read_dir(path)? {
+ let entry = entry?;
+ let path = entry.path();
+ devices.push(PciDevice::from_directory(&path)?);
+ }
+ Ok(devices.into_iter())
+}
diff --git a/crates/konfigkoll_hwinfo/src/pci/parser.rs b/crates/konfigkoll_hwinfo/src/pci/parser.rs
new file mode 100644
index 00000000..dcd8ca9f
--- /dev/null
+++ b/crates/konfigkoll_hwinfo/src/pci/parser.rs
@@ -0,0 +1,658 @@
+//! Parser for pci.ids
+
+use ahash::AHashMap;
+use winnow::{
+ ascii::{hex_uint, newline, space1},
+ combinator::{alt, opt, separated, trace},
+ error::{ContextError, StrContext},
+ stream::AsChar,
+ token::{take, take_until},
+ PResult, Parser,
+};
+
+use super::{Class, ProgrammingInterface, Subclass};
+
+#[derive(Debug, PartialEq, Eq)]
+enum Line<'input> {
+ Class(ClassLine<'input>),
+ Subclass(SubclassLine<'input>),
+ ProgrammingInterface(ProgrammingInterfaceLine<'input>),
+
+ Vendor(VendorLine<'input>),
+ Device(DeviceLine<'input>),
+ Subsystem(SubsystemLine<'input>),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct VendorLine<'input> {
+ id: u16,
+ name: &'input str,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct DeviceLine<'input> {
+ id: u16,
+ name: &'input str,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct SubsystemLine<'input> {
+ subvendor: u16,
+ subdevice: u16,
+ name: &'input str,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct ClassLine<'input> {
+ id: u8,
+ name: &'input str,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct SubclassLine<'input> {
+ id: u8,
+ name: &'input str,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct ProgrammingInterfaceLine<'input> {
+ id: u8,
+ name: &'input str,
+}
+
+/// Sub-error type for the first splitting layer
+#[derive(Debug, PartialEq)]
+pub struct ParsePciError {
+ message: String,
+ pos: usize,
+ input: String,
+}
+
+impl ParsePciError {
+ fn from_parse<'input>(
+ error: &winnow::error::ParseError<&'input str, ContextError>,
+ input: &'input str,
+ ) -> Self {
+ let message = error.inner().to_string();
+ let input = input.to_owned();
+ Self {
+ message,
+ pos: error.offset(),
+ input,
+ }
+ }
+}
+
+impl std::fmt::Display for ParsePciError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let pos = self.pos;
+ let input = &self.input;
+ let message = &self.message;
+ write!(
+ f,
+ "Error at position {}: {}\n{}\n{}^",
+ pos,
+ message,
+ &input[..pos],
+ " ".repeat(pos)
+ )
+ }
+}
+
+impl std::error::Error for ParsePciError {}
+
+pub(super) fn parse_pcidatabase(input: &str) -> anyhow::Result {
+ let lines = parse_file
+ .parse(input)
+ .map_err(|error| ParsePciError::from_parse(&error, input))?;
+ build_hierarchy(&lines)
+}
+
+/// This function takes the line-by-line parsed data and builds a hierarchical
+/// structure from it.
+///
+/// We either need to keep a cursor into the structure we are building (ouch in
+/// Rust), or we need a lookahead of 1 line to determine when to go up a level.
+/// We do the latter, using [`itertools::put_back`].
+fn build_hierarchy(lines: &[Line<'_>]) -> anyhow::Result {
+ let mut db = super::PciIdDb {
+ classes: Default::default(),
+ vendors: Default::default(),
+ };
+
+ let mut lines = itertools::put_back(lines.iter());
+
+ while let Some(line) = lines.next() {
+ match line {
+ Line::Class(class) => {
+ let mut subclasses = AHashMap::new();
+ while let Some(line) = lines.next() {
+ match line {
+ Line::Subclass(subclass) => {
+ let mut prog_ifs = AHashMap::new();
+ while let Some(line) = lines.next() {
+ match line {
+ Line::ProgrammingInterface(prog_if) => {
+ prog_ifs.insert(
+ prog_if.id,
+ ProgrammingInterface {
+ name: prog_if.name.to_string(),
+ },
+ );
+ }
+ _ => {
+ lines.put_back(line);
+ break;
+ }
+ }
+ }
+ subclasses.insert(
+ subclass.id,
+ Subclass {
+ name: subclass.name.to_string(),
+ program_interfaces: prog_ifs,
+ },
+ );
+ }
+ _ => {
+ lines.put_back(line);
+ break;
+ }
+ }
+ }
+ db.classes.insert(
+ class.id,
+ Class {
+ name: class.name.to_string(),
+ subclasses,
+ },
+ );
+ }
+ Line::Vendor(vendor) => {
+ let mut devices = AHashMap::new();
+ while let Some(line) = lines.next() {
+ match line {
+ Line::Device(device) => {
+ let mut subsystems = AHashMap::new();
+ while let Some(line) = lines.next() {
+ match line {
+ Line::Subsystem(subsystem) => {
+ subsystems.insert(
+ (subsystem.subvendor, subsystem.subdevice),
+ super::Subsystem {
+ name: subsystem.name.to_string(),
+ },
+ );
+ }
+ _ => {
+ lines.put_back(line);
+ break;
+ }
+ }
+ }
+ devices.insert(
+ device.id,
+ super::Device {
+ name: device.name.to_string(),
+ subsystems,
+ },
+ );
+ }
+ _ => {
+ lines.put_back(line);
+ break;
+ }
+ }
+ }
+ db.vendors.insert(
+ vendor.id,
+ super::Vendor {
+ name: vendor.name.to_string(),
+ devices,
+ },
+ );
+ }
+ Line::Subclass(_)
+ | Line::ProgrammingInterface(_)
+ | Line::Device(_)
+ | Line::Subsystem(_) => anyhow::bail!("Unexpected line at top level: {line:?}"),
+ }
+ }
+
+ Ok(db)
+}
+
+fn parse_file<'input>(i: &mut &'input str) -> PResult>> {
+ let alternatives = (
+ comment.map(|_| None).context(StrContext::Label("comment")),
+ // Vendor hierarchy
+ vendor
+ .map(|v| Some(Line::Vendor(v)))
+ .context(StrContext::Label("vendor")),
+ device
+ .map(|d| Some(Line::Device(d)))
+ .context(StrContext::Label("device")),
+ subsystem
+ .map(|s| Some(Line::Subsystem(s)))
+ .context(StrContext::Label("subsystem")),
+ // Class hierarchy
+ class
+ .map(|c| Some(Line::Class(c)))
+ .context(StrContext::Label("class")),
+ sub_class
+ .map(|c| Some(Line::Subclass(c)))
+ .context(StrContext::Label("subclass")),
+ prog_if
+ .map(|c| Some(Line::ProgrammingInterface(c)))
+ .context(StrContext::Label("prog_if")),
+ "".map(|_| None).context(StrContext::Label("whitespace")), // Blank lines, must be last
+ );
+ (separated(0.., alt(alternatives), newline), opt(newline))
+ .map(|(val, _): (Vec<_>, _)| {
+ // Filter
+ val.into_iter().flatten().collect()
+ })
+ .parse_next(i)
+}
+
+/// A comment
+fn comment(i: &mut &str) -> PResult<()> {
+ ('#', take_until(0.., '\n')).void().parse_next(i)
+}
+
+fn device<'input>(i: &mut &'input str) -> PResult> {
+ let parser = ('\t', hex4, space1, string).map(|(_, id, _, name)| DeviceLine { id, name });
+ trace("device", parser).parse_next(i)
+}
+
+fn vendor<'input>(i: &mut &'input str) -> PResult> {
+ let parser = (hex4, space1, string).map(|(id, _, name)| VendorLine { id, name });
+ trace("vendor", parser).parse_next(i)
+}
+
+fn subsystem<'input>(i: &mut &'input str) -> PResult> {
+ let parser = ("\t\t", hex4, space1, hex4, space1, string).map(
+ |(_, subvendor, _, subdevice, _, name)| SubsystemLine {
+ subvendor,
+ subdevice,
+ name,
+ },
+ );
+ trace("subsystem", parser).parse_next(i)
+}
+
+fn prog_if<'input>(i: &mut &'input str) -> PResult> {
+ let parser = ("\t\t", hex2, space1, string)
+ .map(|(_, id, _, name)| ProgrammingInterfaceLine { id, name });
+ trace("prog_if", parser).parse_next(i)
+}
+
+fn sub_class<'input>(i: &mut &'input str) -> PResult> {
+ let parser = ('\t', hex2, space1, string).map(|(_, id, _, name)| SubclassLine { id, name });
+ trace("sub_class", parser).parse_next(i)
+}
+
+fn class<'input>(i: &mut &'input str) -> PResult> {
+ let parser =
+ ('C', space1, hex2, space1, string).map(|(_, _, id, _, name)| ClassLine { id, name });
+ trace("class", parser).parse_next(i)
+}
+
+/// A string until the end of the line
+fn string<'input>(i: &mut &'input str) -> PResult<&'input str> {
+ let parser = take_until(0.., '\n');
+
+ trace("string", parser).parse_next(i)
+}
+
+pub fn hex2(i: &mut &str) -> PResult {
+ trace("hex2", take(2usize).verify(is_hex))
+ .and_then(hex_uint::<_, u8, _>)
+ .parse_next(i)
+}
+
+pub fn hex4(i: &mut &str) -> PResult {
+ trace("hex4", take(4usize).verify(is_hex))
+ .and_then(hex_uint::<_, u16, _>)
+ .parse_next(i)
+}
+
+fn is_hex(s: &str) -> bool {
+ for c in s.bytes() {
+ if !AsChar::is_hex_digit(c) {
+ return false;
+ }
+ }
+ true
+}
+
+#[cfg(test)]
+mod tests {
+
+ use crate::pci::{Device, PciIdDb, Subsystem, Vendor};
+
+ use super::*;
+ use indoc::indoc;
+ use pretty_assertions::assert_eq;
+ use winnow::combinator::terminated;
+
+ #[test]
+ fn test_build_hierarchy() {
+ let test_data = vec![
+ Line::Vendor(VendorLine {
+ id: 0x0001,
+ name: "Some ID",
+ }),
+ Line::Vendor(VendorLine {
+ id: 0x0010,
+ name: "Some other ID",
+ }),
+ Line::Device(DeviceLine {
+ id: 0x8139,
+ name: "A device",
+ }),
+ Line::Vendor(VendorLine {
+ id: 0x0014,
+ name: "Another ID",
+ }),
+ Line::Device(DeviceLine {
+ id: 0x0001,
+ name: "ID ID ID",
+ }),
+ Line::Subsystem(SubsystemLine {
+ subvendor: 0x001c,
+ subdevice: 0x0004,
+ name: "Sub device",
+ }),
+ // Classes
+ Line::Class(ClassLine {
+ id: 0x00,
+ name: "CA",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x00,
+ name: "CA 0",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x01,
+ name: "CA 1",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x05,
+ name: "CA 5",
+ }),
+ Line::Class(ClassLine {
+ id: 0x06,
+ name: "CB",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x00,
+ name: "CB 0",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x01,
+ name: "CB 1",
+ }),
+ Line::ProgrammingInterface(ProgrammingInterfaceLine {
+ id: 0x00,
+ name: "CB 1 0",
+ }),
+ Line::ProgrammingInterface(ProgrammingInterfaceLine {
+ id: 0x05,
+ name: "CB 1 5",
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x02,
+ name: "CC",
+ }),
+ ];
+
+ let db = build_hierarchy(&test_data).unwrap();
+
+ assert_eq!(
+ db,
+ PciIdDb {
+ classes: AHashMap::from([
+ (
+ 0,
+ Class {
+ name: "CA".into(),
+ subclasses: AHashMap::from([
+ (
+ 0,
+ Subclass {
+ name: "CA 0".into(),
+ program_interfaces: AHashMap::from([])
+ }
+ ),
+ (
+ 1,
+ Subclass {
+ name: "CA 1".into(),
+ program_interfaces: AHashMap::from([])
+ }
+ ),
+ (
+ 5,
+ Subclass {
+ name: "CA 5".into(),
+ program_interfaces: AHashMap::from([])
+ }
+ ),
+ ])
+ }
+ ),
+ (
+ 6,
+ Class {
+ name: "CB".into(),
+ subclasses: AHashMap::from([
+ (
+ 0,
+ Subclass {
+ name: "CB 0".into(),
+ program_interfaces: AHashMap::from([])
+ }
+ ),
+ (
+ 1,
+ Subclass {
+ name: "CB 1".into(),
+ program_interfaces: AHashMap::from([
+ (
+ 0,
+ ProgrammingInterface {
+ name: "CB 1 0".into()
+ }
+ ),
+ (
+ 5,
+ ProgrammingInterface {
+ name: "CB 1 5".into()
+ }
+ ),
+ ])
+ }
+ ),
+ (
+ 2,
+ Subclass {
+ name: "CC".into(),
+ program_interfaces: AHashMap::from([])
+ }
+ ),
+ ])
+ }
+ ),
+ ]),
+ vendors: AHashMap::from([
+ (
+ 0x0001,
+ Vendor {
+ name: "Some ID".into(),
+ devices: AHashMap::from([])
+ }
+ ),
+ (
+ 0x0010,
+ Vendor {
+ name: "Some other ID".into(),
+ devices: AHashMap::from([(
+ 0x8139,
+ Device {
+ name: "A device".into(),
+ subsystems: AHashMap::from([])
+ }
+ )])
+ }
+ ),
+ (
+ 0x0014,
+ Vendor {
+ name: "Another ID".into(),
+ devices: AHashMap::from([(
+ 0x0001,
+ Device {
+ name: "ID ID ID".into(),
+ subsystems: AHashMap::from([(
+ (0x001c, 0x0004),
+ Subsystem {
+ name: "Sub device".into()
+ }
+ )])
+ }
+ )])
+ }
+ ),
+ ])
+ }
+ );
+ }
+
+ const TEST_DATA: &str = indoc! {
+"0001 Some ID
+0010 Some other ID
+# A Comment
+\t8139 A device
+0014 Another ID
+\t0001 ID ID ID
+\t\t001c 0004 Sub device
+
+# A comment
+
+C 00 CA
+\t00 CA 0
+\t01 CA 1
+\t05 CA 5
+C 01 CB
+\t00 CB 0
+\t01 CB 1
+\t\t00 CB 1 0
+\t\t05 CB 1 5
+\t02 CC\n"};
+
+ #[test]
+ fn test_parse_file() {
+ let parsed = parse_file.parse(TEST_DATA).unwrap();
+
+ assert_eq!(
+ parsed,
+ vec![
+ Line::Vendor(VendorLine {
+ id: 0x0001,
+ name: "Some ID"
+ }),
+ Line::Vendor(VendorLine {
+ id: 0x0010,
+ name: "Some other ID"
+ }),
+ Line::Device(DeviceLine {
+ id: 0x8139,
+ name: "A device"
+ }),
+ Line::Vendor(VendorLine {
+ id: 0x0014,
+ name: "Another ID"
+ }),
+ Line::Device(DeviceLine {
+ id: 0x0001,
+ name: "ID ID ID"
+ }),
+ Line::Subsystem(SubsystemLine {
+ subvendor: 0x001c,
+ subdevice: 0x0004,
+ name: "Sub device"
+ }),
+ Line::Class(ClassLine {
+ id: 0x00,
+ name: "CA"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x00,
+ name: "CA 0"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x01,
+ name: "CA 1"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x05,
+ name: "CA 5"
+ }),
+ Line::Class(ClassLine {
+ id: 0x01,
+ name: "CB"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x00,
+ name: "CB 0"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x01,
+ name: "CB 1"
+ }),
+ Line::ProgrammingInterface(ProgrammingInterfaceLine {
+ id: 0x00,
+ name: "CB 1 0"
+ }),
+ Line::ProgrammingInterface(ProgrammingInterfaceLine {
+ id: 0x05,
+ name: "CB 1 5"
+ }),
+ Line::Subclass(SubclassLine {
+ id: 0x02,
+ name: "CC"
+ }),
+ ]
+ );
+ }
+
+ #[test]
+ fn test_class() {
+ let parsed = terminated(class, newline)
+ .parse("C 00 Something\n")
+ .unwrap();
+
+ assert_eq!(
+ parsed,
+ ClassLine {
+ id: 0,
+ name: "Something"
+ }
+ );
+ }
+
+ #[test]
+ fn test_sub_class() {
+ let parsed = terminated(sub_class, newline)
+ .parse("\t0f Some string\n")
+ .unwrap();
+ assert_eq!(
+ parsed,
+ SubclassLine {
+ id: 0x0f,
+ name: "Some string"
+ }
+ );
+ }
+}
diff --git a/crates/konfigkoll_script/Cargo.toml b/crates/konfigkoll_script/Cargo.toml
new file mode 100644
index 00000000..94b84316
--- /dev/null
+++ b/crates/konfigkoll_script/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+description = "Scripting language for Konfigkoll (not for direct public use)"
+edition = "2021"
+license = "MPL-2.0"
+name = "konfigkoll_script"
+repository = "https://github.com/VorpalBlade/paketkoll"
+rust-version = "1.79.0"
+version = "0.1.0"
+
+[features]
+# Default features
+default = ["arch_linux", "debian"]
+
+# Include the Arch Linux backend
+arch_linux = ["paketkoll_core/arch_linux"]
+
+# Include support for the Debian backend
+debian = ["paketkoll_core/debian"]
+
+[dependencies]
+ahash.workspace = true
+anyhow.workspace = true
+camino.workspace = true
+compact_str.workspace = true
+glob.workspace = true
+itertools.workspace = true
+konfigkoll_core = { version = "0.1.0", path = "../konfigkoll_core" }
+konfigkoll_hwinfo = { version = "0.1.0", path = "../konfigkoll_hwinfo", features = [
+ "rune",
+] }
+konfigkoll_types = { version = "0.1.0", path = "../konfigkoll_types" }
+nix = { workspace = true, features = ["user"] }
+paketkoll_core = { version = "0.4.1", path = "../paketkoll_core" }
+paketkoll_types = { version = "0.1.0", path = "../paketkoll_types", features = [
+ "serde",
+] }
+paketkoll_utils = { version = "0.1.0", path = "../paketkoll_utils" }
+parking_lot.workspace = true
+regex.workspace = true
+rune.workspace = true
+rune-modules = { workspace = true, features = ["process", "json", "toml", "tokio"] }
+rust-ini.workspace = true
+smallvec.workspace = true
+sysinfo.workspace = true
+tempfile = { workspace = true }
+thiserror.workspace = true
+tokio = { workspace = true, features = ["process"] }
+tracing.workspace = true
+winnow = { workspace = true, features = ["simd"] }
+
+[lints]
+workspace = true
+
+[dev-dependencies]
+indoc.workspace = true
+pretty_assertions.workspace = true
diff --git a/crates/konfigkoll_script/README.md b/crates/konfigkoll_script/README.md
new file mode 100644
index 00000000..b9c00507
--- /dev/null
+++ b/crates/konfigkoll_script/README.md
@@ -0,0 +1,13 @@
+# konfigkoll_script
+
+Scripting language interface for konfigkoll.
+
+This provides the glue between Rust and Rune, in particular the custom Rune
+modules that konfigkoll provides.
+
+This is an internal crate with no stability guarantees whatsoever on the
+Rust side. The Rune API is also currently heavily unstable but is expected
+to be stabilized in the future.
+
+You should use [`konfigkoll`](https://crates.io/crates/konfigkoll) the command
+line tool instead.
diff --git a/crates/konfigkoll_script/src/engine.rs b/crates/konfigkoll_script/src/engine.rs
new file mode 100644
index 00000000..1419799b
--- /dev/null
+++ b/crates/konfigkoll_script/src/engine.rs
@@ -0,0 +1,305 @@
+use std::{
+ collections::BTreeMap,
+ fmt::Display,
+ io::Write,
+ panic::{catch_unwind, AssertUnwindSafe},
+ sync::{Arc, OnceLock},
+};
+
+use crate::plugins::{
+ command::Commands, package_managers::PackageManagers, properties::Properties,
+ settings::Settings,
+};
+use anyhow::Context;
+use camino::{Utf8Path, Utf8PathBuf};
+use paketkoll_types::{
+ backend::{Backend, Files, PackageBackendMap, PackageMap},
+ intern::Interner,
+};
+use rune::{
+ termcolor::{ColorChoice, StandardStream},
+ Diagnostics, Source, Vm,
+};
+
+/// Describe the phases of script evaluation.
+///
+/// Each phase is a separate function defined by the top level script.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Phase {
+ /// During this phase, the script can discover information about the system
+ /// and hardware, and set properties for later use.
+ #[default]
+ SystemDiscovery,
+ /// During this phase file system ignores should be set up. These are
+ /// needed by the file system scan code that will be started concurrently
+ /// after this.
+ Ignores,
+ /// Early package dependencies that are needed by the main phase should be
+ /// declared here. These packages will be installed before the main config
+ /// runs if they are missing.
+ ScriptDependencies,
+ /// During the main phase the config proper is generated.
+ Main,
+}
+
+impl Phase {
+ /// Convert to string
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::SystemDiscovery => "phase_system_discovery",
+ Self::Ignores => "phase_ignores",
+ Self::ScriptDependencies => "phase_script_dependencies",
+ Self::Main => "phase_main",
+ }
+ }
+}
+
+impl Display for Phase {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+/// State being built up by the scripts as it runs
+#[derive(Debug)]
+pub struct EngineState {
+ /// Properties set by the user
+ pub(crate) properties: Properties,
+ /// Commands to be applied to the system
+ pub(crate) commands: Commands,
+ /// Settings of how konfigkoll should behave.
+ pub(crate) settings: Arc,
+ /// All the enabled package managers
+ pub(crate) package_managers: Option,
+}
+
+/// Path to the configuration directory
+pub(crate) static CFG_PATH: OnceLock = OnceLock::new();
+
+impl EngineState {
+ pub fn new(files_path: Utf8PathBuf) -> Self {
+ let settings = Arc::new(Settings::default());
+ Self {
+ properties: Default::default(),
+ commands: Commands::new(files_path, settings.clone()),
+ settings,
+ package_managers: None,
+ }
+ }
+
+ pub fn setup_package_managers(
+ &mut self,
+ package_backends: &PackageBackendMap,
+ file_backend_id: Backend,
+ files_backend: &Arc,
+ package_maps: &BTreeMap>,
+ interner: &Arc,
+ ) {
+ self.package_managers = Some(PackageManagers::create_from(
+ package_backends,
+ file_backend_id,
+ files_backend,
+ package_maps,
+ interner,
+ ));
+ }
+
+ pub fn settings(&self) -> Arc {
+ Arc::clone(&self.settings)
+ }
+
+ pub fn commands(&self) -> &Commands {
+ &self.commands
+ }
+
+ pub fn commands_mut(&mut self) -> &mut Commands {
+ &mut self.commands
+ }
+}
+
+/// The script engine that is the main entry point for this crate.
+#[derive(Debug)]
+pub struct ScriptEngine {
+ runtime: Arc,
+ sources: rune::Sources,
+ /// User scripts
+ unit: Arc,
+ /// Properties exposed by us or set by the user
+ pub(crate) state: EngineState,
+}
+
+impl ScriptEngine {
+ pub fn create_context() -> Result {
+ let mut context = rune::Context::with_default_modules()?;
+
+ // Register modules
+ crate::plugins::register_modules(&mut context)?;
+ context.install(rune_modules::json::module(true)?)?;
+ context.install(rune_modules::toml::module(true)?)?;
+ context.install(rune_modules::toml::de::module(true)?)?;
+ context.install(rune_modules::toml::ser::module(true)?)?;
+
+ Ok(context)
+ }
+
+ pub fn new_with_files(config_path: &Utf8Path) -> anyhow::Result {
+ CFG_PATH.set(config_path.to_owned()).map_err(|v| {
+ anyhow::anyhow!(
+ "Failed to set CFG_PATH to {v}, this should not be called more than once"
+ )
+ })?;
+ let context = Self::create_context()?;
+
+ // Create state
+ let state = EngineState::new(config_path.join("files"));
+
+ // Load scripts
+ let mut diagnostics = Diagnostics::new();
+
+ let mut sources = rune::Sources::new();
+ sources
+ .insert(
+ Source::from_path(config_path.join("main.rn")).context("Failed to load main.rn")?,
+ )
+ .context("Failed to insert source file")?;
+
+ let result = rune::prepare(&mut sources)
+ .with_context(&context)
+ .with_diagnostics(&mut diagnostics)
+ .build();
+
+ if !diagnostics.is_empty() {
+ let mut writer = StandardStream::stderr(ColorChoice::Always);
+ diagnostics.emit(&mut writer, &sources)?;
+ }
+
+ // Create ScriptEngine
+ Ok(Self {
+ runtime: Arc::new(context.runtime()?),
+ sources,
+ state,
+ unit: Arc::new(result?),
+ })
+ }
+
+ /// Call a function in the script
+ #[tracing::instrument(level = "info", name = "script", skip(self))]
+ pub async fn run_phase(&mut self, phase: Phase) -> anyhow::Result<()> {
+ // Update phase in relevant state
+ self.state.commands.phase = phase;
+ // Create VM and do call
+ let mut vm = Vm::new(self.runtime.clone(), self.unit.clone());
+ tracing::info!("Calling script");
+ let output = match phase {
+ Phase::SystemDiscovery => {
+ vm.async_call(
+ [phase.as_str()],
+ (&mut self.state.properties, self.state.settings.as_ref()),
+ )
+ .await
+ }
+ Phase::Ignores | Phase::ScriptDependencies => {
+ vm.async_call(
+ [phase.as_str()],
+ (&mut self.state.properties, &mut self.state.commands),
+ )
+ .await
+ }
+ Phase::Main => {
+ vm.async_call(
+ [phase.as_str()],
+ (
+ &mut self.state.properties,
+ &mut self.state.commands,
+ self.state
+ .package_managers
+ .as_ref()
+ .expect("Package managers must be set"),
+ ),
+ )
+ .await
+ }
+ };
+ // Handle rune runtime errors
+ let output = match output {
+ Ok(output) => output,
+ Err(e) => {
+ let err_str = format!("Rune error while executing {phase}: {}", &e);
+ tracing::error!("{}", err_str);
+ let mut writer = StandardStream::stderr(ColorChoice::Always);
+ writer.write_all(b"\n------\n\n")?;
+ e.emit(&mut writer, &self.sources)?;
+ writer.write_all(b"\n------\n\n")?;
+
+ return Err(e).context(err_str);
+ }
+ };
+ tracing::info!("Returned from script");
+ // Do error handling on the returned result
+ match output {
+ rune::Value::Result(result) => match result.borrow_ref()?.as_ref() {
+ Ok(_) => (),
+ Err(e) => vm.with(|| try_format_error(phase, e))?,
+ },
+ _ => anyhow::bail!("Got non-result from {phase}: {output:?}"),
+ }
+ Ok(())
+ }
+
+ #[inline]
+ pub fn state(&self) -> &EngineState {
+ &self.state
+ }
+
+ #[inline]
+ pub fn state_mut(&mut self) -> &mut EngineState {
+ &mut self.state
+ }
+}
+
+/// Attempt to format the error in the best way possible.
+///
+/// Unfortunately this is awkward with dynamic Rune values.
+fn try_format_error(phase: Phase, value: &rune::Value) -> anyhow::Result<()> {
+ match value.clone().into_any() {
+ rune::runtime::VmResult::Ok(any) => {
+ if let Ok(err) = any.downcast_borrow_ref::() {
+ anyhow::bail!("Got error result from {phase}: {:?}", *err);
+ }
+ if let Ok(err) = any.downcast_borrow_ref::() {
+ anyhow::bail!("Got IO error result from {phase}: {:?}", *err);
+ }
+ let ty = try_get_type_info(value, "error");
+ let formatted = catch_unwind(AssertUnwindSafe(|| format!("{value:?}")));
+ anyhow::bail!(
+ "Got error result from {phase}, but it is a unknown error type: {ty}: {any:?}, formats as: {formatted:?}",
+ );
+ }
+ rune::runtime::VmResult::Err(not_any) => {
+ tracing::error!("Got error result from {phase}, it was not an Any: {not_any:?}. Trying other approches at printing the error.");
+ }
+ }
+ // Attempt to format the error
+ let formatted = catch_unwind(AssertUnwindSafe(|| {
+ format!("Got error result from {phase}: {value:?}")
+ }));
+ match formatted {
+ Ok(str) => anyhow::bail!(str),
+ Err(_) => {
+ let ty = try_get_type_info(value, "error");
+ anyhow::bail!(
+ "Got error result from {phase}, but got a panic while attempting to format said error for printing, {ty}",
+ );
+ }
+ }
+}
+
+/// Best effort attempt at gettint the type info and printing it
+fn try_get_type_info(e: &rune::Value, what: &str) -> String {
+ match e.type_info() {
+ rune::runtime::VmResult::Ok(ty) => format!("type info for {what}: {ty:?}"),
+ rune::runtime::VmResult::Err(err) => {
+ format!("failed getting type info for {what}: {err:?}")
+ }
+ }
+}
diff --git a/crates/konfigkoll_script/src/lib.rs b/crates/konfigkoll_script/src/lib.rs
new file mode 100644
index 00000000..ebf8aca5
--- /dev/null
+++ b/crates/konfigkoll_script/src/lib.rs
@@ -0,0 +1,20 @@
+//! Scripting language interface for konfigkoll.
+//!
+//! This provides the glue between Rust and Rune, in particular the custom
+//! Rune modules that konfigkoll provides.
+//!
+//! This is an internal crate with no stability guarantees whatsoever on the
+//! Rust side. The Rune API is also currently heavily unstable but is expected
+//! to be stabilized in the future.
+//!
+//! You should use [`konfigkoll`](https://crates.io/crates/konfigkoll) the
+//! command line tool instead.
+
+mod engine;
+mod plugins;
+
+pub use engine::EngineState;
+pub use engine::Phase;
+pub use engine::ScriptEngine;
+pub use plugins::command::Commands;
+pub use plugins::settings::Settings;
diff --git a/crates/konfigkoll_script/src/plugins.rs b/crates/konfigkoll_script/src/plugins.rs
new file mode 100644
index 00000000..ef415ed6
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins.rs
@@ -0,0 +1,29 @@
+//! RHAI plugins for Konfigkoll
+
+pub(crate) mod command;
+mod filesystem;
+pub mod package_managers;
+mod passwd;
+mod patch;
+mod process;
+pub(crate) mod properties;
+pub(crate) mod regex;
+pub(crate) mod settings;
+mod sysinfo;
+mod systemd;
+
+pub(crate) fn register_modules(context: &mut rune::Context) -> Result<(), rune::ContextError> {
+ context.install(command::module()?)?;
+ context.install(filesystem::module()?)?;
+ context.install(package_managers::module()?)?;
+ context.install(passwd::module()?)?;
+ context.install(patch::module()?)?;
+ context.install(process::module(true)?)?;
+ context.install(properties::module()?)?;
+ context.install(regex::module()?)?;
+ context.install(settings::module()?)?;
+ context.install(sysinfo::module()?)?;
+ context.install(systemd::module()?)?;
+
+ Ok(())
+}
diff --git a/crates/konfigkoll_script/src/plugins/command.rs b/crates/konfigkoll_script/src/plugins/command.rs
new file mode 100644
index 00000000..f6d1ae8a
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins/command.rs
@@ -0,0 +1,366 @@
+//! Commands to change the configuration
+//!
+//! These are the important ones, the ones that describe how the system should be changed.
+
+use std::{str::FromStr, sync::Arc};
+
+use ahash::AHashSet;
+use anyhow::Context;
+use camino::Utf8PathBuf;
+use compact_str::CompactString;
+use konfigkoll_core::utils::safe_path_join;
+use konfigkoll_types::{
+ FileContents, FsInstruction, FsOp, FsOpDiscriminants, PkgIdent, PkgInstruction,
+ PkgInstructions, PkgOp,
+};
+use paketkoll_types::{backend::Backend, files::Mode};
+use rune::{ContextError, Module, Value};
+
+use crate::Phase;
+
+use super::settings::Settings;
+
+#[derive(Debug, Clone, rune::Any)]
+#[rune(item = ::command)]
+/// The changes to apply to the system.
+///
+/// This is what will be compared to the installed system
+pub struct Commands {
+ /// The current phase
+ pub(crate) phase: Phase,
+ /// Base path to files directory
+ pub(crate) base_files_path: Utf8PathBuf,
+ /// Set of file system ignores
+ pub fs_ignores: AHashSet,
+ /// Queue of file system instructions
+ pub fs_actions: Vec,
+ /// Queue of package instructions
+ pub package_actions: PkgInstructions,
+ /// Settings
+ settings: Arc,
+}
+
+/// Rust API
+impl Commands {
+ pub(crate) fn new(base_files_path: Utf8PathBuf, settings: Arc) -> Self {
+ Self {
+ phase: Phase::SystemDiscovery,
+ base_files_path,
+ fs_ignores: AHashSet::new(),
+ fs_actions: Vec::new(),
+ package_actions: PkgInstructions::new(),
+ settings,
+ }
+ }
+
+ /// Get the contents of an set file
+ pub(crate) fn file_contents(&self, path: &str) -> Option<&FileContents> {
+ self.fs_actions
+ .iter()
+ .rfind(|i| {
+ i.path == path && FsOpDiscriminants::from(&i.op) == FsOpDiscriminants::CreateFile
+ })
+ .map(|i| match &i.op {
+ FsOp::CreateFile(contents) => contents,
+ _ => unreachable!(),
+ })
+ }
+}
+
+/// Rune API
+impl Commands {
+ /// Ignore a path, preventing it from being scanned for differences
+ #[rune::function(keep)]
+ pub fn ignore_path(&mut self, ignore: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Ignores {
+ return Err(anyhow::anyhow!(
+ "Can only ignore paths during the 'ignores' phase"
+ ));
+ }
+ if !self.fs_ignores.insert(ignore.into()) {
+ tracing::warn!("Ignoring path '{}' multiple times", ignore);
+ }
+ Ok(())
+ }
+
+ /// Install a package with the given package manager.
+ ///
+ /// If the package manager isn't enabled, this will be a no-op.
+ #[rune::function(keep)]
+ pub fn add_pkg(&mut self, package_manager: &str, identifier: &str) -> anyhow::Result<()> {
+ if self.phase < Phase::ScriptDependencies {
+ return Err(anyhow::anyhow!(
+ "Can only add packages during the 'script_dependencies' or 'main' phases"
+ ));
+ }
+ let backend = Backend::from_str(package_manager).context("Invalid backend")?;
+ if !self.settings.is_pkg_backend_enabled(backend) {
+ tracing::info!("Skipping disabled package manager {}", package_manager);
+ return Ok(());
+ }
+ if self
+ .package_actions
+ .insert(
+ PkgIdent {
+ package_manager: backend,
+ identifier: identifier.into(),
+ },
+ PkgInstruction {
+ op: PkgOp::Install,
+ comment: None,
+ },
+ )
+ .is_some()
+ {
+ tracing::warn!("Multiple actions for package '{package_manager}:{identifier}'",);
+ }
+ Ok(())
+ }
+
+ /// Remove a package with the given package manager.
+ ///
+ /// If the package manager isn't enabled, this will be a no-op.
+ #[rune::function(keep)]
+ pub fn remove_pkg(&mut self, package_manager: &str, identifier: &str) -> anyhow::Result<()> {
+ if self.phase < Phase::ScriptDependencies {
+ return Err(anyhow::anyhow!(
+ "Can only add packages during the 'script_dependencies' or 'main' phases"
+ ));
+ }
+ let backend = Backend::from_str(package_manager).context("Invalid backend")?;
+ if !self.settings.is_file_backend_enabled(backend) {
+ tracing::debug!("Skipping disabled package manager {}", package_manager);
+ return Ok(());
+ }
+ if self
+ .package_actions
+ .insert(
+ PkgIdent {
+ package_manager: backend,
+ identifier: identifier.into(),
+ },
+ PkgInstruction {
+ op: PkgOp::Uninstall,
+ comment: None,
+ },
+ )
+ .is_some()
+ {
+ tracing::warn!("Multiple actions for package '{package_manager}:{identifier}'",);
+ }
+ Ok(())
+ }
+
+ /// Remove a path
+ #[rune::function(keep)]
+ pub fn rm(&mut self, path: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::Remove,
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Check if a file exists in the `files/` sub-directory to the configuration
+ #[rune::function(keep)]
+ pub fn has_source_file(&self, path: &str) -> bool {
+ let path = safe_path_join(&self.base_files_path, path.into());
+ path.exists()
+ }
+
+ /// Create a file with the given contents
+ #[rune::function(keep)]
+ pub fn copy(&mut self, path: &str) -> anyhow::Result<()> {
+ self.copy_from(path, path)
+ }
+
+ /// Create a file with the given contents (renaming the file in the process)
+ ///
+ /// The rename is useful to copy a file to a different location (e.g. `etc/fstab.hostname` to `etc/fstab`)
+ #[rune::function(keep)]
+ pub fn copy_from(&mut self, path: &str, src: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+ let contents = FileContents::from_file(&safe_path_join(&self.base_files_path, src.into()));
+ let contents = match contents {
+ Ok(v) => v,
+ Err(e) => {
+ tracing::error!("Failed to read file contents for '{}': {}", path, e);
+ return Err(anyhow::anyhow!(
+ "Failed to read file contents for '{}': {}",
+ path,
+ e
+ ));
+ }
+ };
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::CreateFile(contents),
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Create a symlink
+ #[rune::function(keep)]
+ pub fn ln(&mut self, path: &str, target: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::CreateSymlink {
+ target: target.into(),
+ },
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Create a file with the given contents
+ #[rune::function(keep)]
+ pub fn write(&mut self, path: &str, contents: &[u8]) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::CreateFile(FileContents::from_literal(contents.into())),
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Create a directory
+ #[rune::function(keep)]
+ pub fn mkdir(&mut self, path: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::CreateDirectory,
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Change file owner
+ #[rune::function(keep)]
+ pub fn chown(&mut self, path: &str, owner: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::SetOwner {
+ owner: owner.into(),
+ },
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Change file group
+ #[rune::function(keep)]
+ pub fn chgrp(&mut self, path: &str, group: &str) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::SetGroup {
+ group: group.into(),
+ },
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Change file mode
+ #[rune::function(keep)]
+ pub fn chmod(&mut self, path: &str, mode: Value) -> anyhow::Result<()> {
+ if self.phase != Phase::Main {
+ return Err(anyhow::anyhow!(
+ "File system actions are only possible in the 'main' phase"
+ ));
+ }
+
+ let numeric_mode = match mode {
+ Value::Integer(m) => Mode::new(m as u32),
+ Value::String(str) => {
+ let guard = str.borrow_ref()?;
+ // Convert text mode (u+rx,g+rw,o+r, etc) to numeric mode
+ Mode::parse(&guard)?
+ }
+ _ => return Err(anyhow::anyhow!("Invalid mode value")),
+ };
+
+ self.fs_actions.push(FsInstruction {
+ op: konfigkoll_types::FsOp::SetMode { mode: numeric_mode },
+ path: path.into(),
+ comment: None,
+ });
+ Ok(())
+ }
+
+ /// Set all permissions at once
+ #[rune::function(keep)]
+ pub fn perms(
+ &mut self,
+ path: &str,
+ owner: &str,
+ group: &str,
+ mode: Value,
+ ) -> anyhow::Result<()> {
+ self.chown(path, owner)?;
+ self.chgrp(path, group)?;
+ self.chmod(path, mode)?;
+ Ok(())
+ }
+}
+
+#[rune::module(::command)]
+/// Commands describe the changes to apply to the system
+pub(crate) fn module() -> Result {
+ let mut m = Module::from_meta(self::module_meta)?;
+ m.ty::()?;
+ m.function_meta(Commands::ignore_path__meta)?;
+ m.function_meta(Commands::add_pkg__meta)?;
+ m.function_meta(Commands::remove_pkg__meta)?;
+ m.function_meta(Commands::rm__meta)?;
+ m.function_meta(Commands::has_source_file__meta)?;
+ m.function_meta(Commands::copy__meta)?;
+ m.function_meta(Commands::copy_from__meta)?;
+ m.function_meta(Commands::ln__meta)?;
+ m.function_meta(Commands::write__meta)?;
+ m.function_meta(Commands::mkdir__meta)?;
+
+ m.function_meta(Commands::chown__meta)?;
+ m.function_meta(Commands::chgrp__meta)?;
+ m.function_meta(Commands::chmod__meta)?;
+ m.function_meta(Commands::perms__meta)?;
+
+ Ok(m)
+}
diff --git a/crates/konfigkoll_script/src/plugins/filesystem.rs b/crates/konfigkoll_script/src/plugins/filesystem.rs
new file mode 100644
index 00000000..df2316e6
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins/filesystem.rs
@@ -0,0 +1,258 @@
+//! Host file system access
+
+use std::io::{ErrorKind, Read};
+
+use anyhow::Context;
+use camino::Utf8PathBuf;
+use konfigkoll_core::utils::safe_path_join;
+use rune::alloc::fmt::TryWrite;
+use rune::{
+ runtime::{Bytes, Formatter},
+ vm_write, Any, ContextError, Module,
+};
+
+use crate::engine::CFG_PATH;
+
+/// A file error
+#[derive(Debug, Any, thiserror::Error)]
+#[rune(item = ::filesystem)]
+enum FileError {
+ #[error("IO Error: {0}")]
+ IoError(#[from] std::io::Error),
+ #[error("Allocation error: {0}")]
+ AllocError(#[from] rune::alloc::Error),
+}
+
+impl FileError {
+ #[rune::function(vm_result, protocol = STRING_DISPLAY)]
+ pub(crate) fn display(&self, f: &mut Formatter) {
+ vm_write!(f, "{}", self);
+ }
+
+ #[rune::function(vm_result, protocol = STRING_DEBUG)]
+ pub(crate) fn debug(&self, f: &mut Formatter) {
+ vm_write!(f, "{:?}", self);
+ }
+}
+
+/// Represents a temporary directory
+///
+/// The directory will be removed when this object is dropped
+#[derive(Debug, Any)]
+#[rune(item = ::filesystem)]
+struct TempDir {
+ path: Utf8PathBuf,
+}
+
+impl Drop for TempDir {
+ fn drop(&mut self) {
+ std::fs::remove_dir_all(&self.path).expect("Failed to remove temporary directory");
+ }
+}
+
+impl TempDir {
+ #[rune::function(vm_result, protocol = STRING_DEBUG)]
+ fn debug(&self, f: &mut Formatter) {
+ vm_write!(f, "{:?}", self);
+ }
+
+ /// Create a new temporary directory
+ #[rune::function(path = Self::new)]
+ fn new() -> anyhow::Result {
+ let dir = tempfile::TempDir::with_prefix("konfigkoll_")?.into_path();
+ match Utf8PathBuf::from_path_buf(dir) {
+ Ok(path) => Ok(Self { path }),
+ Err(path) => {
+ std::fs::remove_dir_all(&path).expect("Failed to remove temporary directory");
+ Err(anyhow::anyhow!("Failed to convert path to utf8: {path:?}"))
+ }
+ }
+ }
+
+ /// Get the path to the temporary directory
+ #[rune::function]
+ fn path(&self) -> String {
+ self.path.to_string()
+ }
+
+ /// Write a temporary file under this directory, getting it's path path
+ #[rune::function]
+ fn write(&self, path: &str, contents: &[u8]) -> anyhow::Result {
+ let p = safe_path_join(&self.path, path.into());
+ std::fs::write(&p, contents).with_context(|| format!("Failed to write to {p}"))?;
+ Ok(p.into_string())
+ }
+
+ /// Read a file from the temporary directory
+ ///
+ /// Returns a `Result`
+ #[rune::function]
+ fn read(&self, path: &str) -> anyhow::Result {
+ let p = safe_path_join(&self.path, path.into());
+ let data = std::fs::read(&p).with_context(|| format!("Failed to read {p}"))?;
+ Ok(Bytes::from_vec(data.try_into()?))
+ }
+}
+
+/// Represents an open file
+#[derive(Debug, Any)]
+#[rune(item = ::filesystem)]
+struct File {
+ file: std::fs::File,
+ // TODO: Needed for future privilege separation
+ #[allow(dead_code)]
+ need_root: bool,
+}
+
+/// Rune API
+impl File {
+ #[rune::function(vm_result, protocol = STRING_DEBUG)]
+ pub(crate) fn debug(&self, f: &mut Formatter) {
+ vm_write!(f, "{:?}", self);
+ }
+
+ /// Open a file (with normal user permissions)
+ #[rune::function(path = Self::open)]
+ pub fn open(path: &str) -> anyhow::Result {
+ let file = std::fs::File::open(path).with_context(|| format!("Failed to open {path}"))?;
+ Ok(Self {
+ file,
+ need_root: false,
+ })
+ }
+
+ /// Open a file as root
+ #[rune::function(path = Self::open_as_root)]
+ pub fn open_as_root(path: &str) -> anyhow::Result {
+ let file =
+ std::fs::File::open(path).with_context(|| format!("Failed to open {path} as root"))?;
+ Ok(Self {
+ file,
+ need_root: true,
+ })
+ }
+
+ /// Open a file relative to the config directory.
+ ///
+ /// This is generally safe (as long as the file exists in the config directory)
+ #[rune::function(path = Self::open_from_config)]
+ pub fn open_from_config(path: &str) -> anyhow::Result {
+ let p = safe_path_join(CFG_PATH.get().expect("CFG_PATH not set"), path.into());
+ let file = std::fs::File::open(&p)
+ .with_context(|| format!("Failed to open {path} from config directory, tried {p}"))?;
+ Ok(Self {
+ file,
+ need_root: false,
+ })
+ }
+
+ /// Read the entire file as a string
+ #[rune::function]
+ pub fn read_all_string(&mut self) -> Result {
+ let mut buf = String::new();
+ self.file.read_to_string(&mut buf)?;
+ Ok(buf)
+ }
+
+ /// Read the entire file as bytes
+ #[rune::function]
+ pub fn read_all_bytes(&mut self) -> Result {
+ let mut buf = Vec::new();
+ self.file.read_to_end(&mut buf)?;
+ let buf = rune::alloc::Vec::try_from(buf)?;
+ Ok(buf.into())
+ }
+}
+
+/// Check if a path exists
+///
+/// Returns a `Result`
+#[rune::function]
+fn exists(path: &str) -> Result {
+ let metadata = std::fs::symlink_metadata(path);
+
+ match metadata {
+ Ok(_) => Ok(true),
+ Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+/// Run a glob pattern against the host file system
+///
+/// Returns a `Result>`
+#[rune::function]
+fn glob(pattern: &str) -> anyhow::Result> {
+ let paths = glob::glob(pattern).context("Failed to construct glob")?;
+
+ let mut result = Vec::new();
+ for path in paths {
+ result.push(path?.to_string_lossy().to_string());
+ }
+
+ Ok(result)
+}
+
+/// Get the path to the configuration directory
+///
+/// **Prefer `File::open_from_config` instead if you just want to load data from the config directory**
+///
+/// This is primarily useful together with the `process` module to pass a
+/// path to a file from the configuration directory to an external command.
+#[rune::function]
+fn config_path() -> String {
+ CFG_PATH.get().expect("CFG_PATH not set").to_string()
+}
+
+#[rune::module(::filesystem)]
+/// Read only access to the host file system and the configuration directory
+///
+/// # Host file system access
+///
+/// Be careful with host file system access, since it can make your configuration non-deterministic.
+///
+/// The main purpose of this is for things that *shouldn't* be stored in your git
+/// managed configuration, in particular for passwords and other secrets:
+///
+/// * Hashed passwords from `/etc/shadow`
+/// * Passwords for wireless networks
+/// * Passwords for any services needed (such as databases)
+///
+/// Another use case is to read some system information from `/sys` that isn't
+/// already exposed by other APIs
+///
+/// # Configuration directory access
+///
+/// This is generally safe, in order to read files that are part of the configuration
+/// (if you want to use them as templates for example and fill in some values)
+///
+/// Use `File::open_from_config` for this. In special circumstances (together with the `process` module)
+/// you may also need [`config_path`].
+///
+/// # Temporary directories
+///
+/// This is generally not needed when working with konfigkoll, but can be useful
+/// for interacting with external commands via the `process` module.
+pub(crate) fn module() -> Result {
+ let mut m = Module::from_meta(self::module_meta)?;
+ m.ty::()?;
+ m.function_meta(File::debug)?;
+ m.function_meta(File::open)?;
+ m.function_meta(File::open_as_root)?;
+ m.function_meta(File::open_from_config)?;
+ m.function_meta(File::read_all_string)?;
+ m.function_meta(File::read_all_bytes)?;
+ m.ty::()?;
+ m.function_meta(FileError::display)?;
+ m.function_meta(FileError::debug)?;
+ m.ty::()?;
+ m.function_meta(TempDir::debug)?;
+ m.function_meta(TempDir::new)?;
+ m.function_meta(TempDir::path)?;
+ m.function_meta(TempDir::read)?;
+ m.function_meta(TempDir::write)?;
+ m.function_meta(exists)?;
+ m.function_meta(glob)?;
+ m.function_meta(config_path)?;
+ Ok(m)
+}
diff --git a/crates/konfigkoll_script/src/plugins/package_managers.rs b/crates/konfigkoll_script/src/plugins/package_managers.rs
new file mode 100644
index 00000000..7c4ef5f3
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins/package_managers.rs
@@ -0,0 +1,189 @@
+//! Access to system package manager
+
+use std::{collections::BTreeMap, str::FromStr, sync::Arc};
+
+use anyhow::Context;
+use paketkoll_types::{
+ backend::{Backend, Files, OriginalFileQuery, PackageBackendMap, PackageMap, Packages},
+ intern::Interner,
+};
+use rune::{
+ runtime::{Bytes, Shared},
+ Any, ContextError, Module,
+};
+
+/// Type of map for package managers
+pub type PackageManagerMap = BTreeMap;
+
+#[derive(Debug, Any)]
+#[rune(item = ::package_managers)]
+/// The collection of enabled package managers
+pub struct PackageManagers {
+ package_managers: PackageManagerMap,
+ backend_with_files: Backend,
+}
+
+impl PackageManagers {
+ /// Create a new package managers
+ pub fn create_from(
+ package_backends: &PackageBackendMap,
+ file_backend_id: Backend,
+ files_backend: &Arc,
+ package_maps: &BTreeMap>,
+ interner: &Arc,
+ ) -> Self {
+ let files_backends = [(file_backend_id, files_backend)];
+ // Join all three maps on key. This is equivalent to a SQL outer join.
+ // Use itertools::merge_join_by for this.
+ let merged =
+ itertools::merge_join_by(package_backends, files_backends, |l, r| l.0.cmp(&r.0));
+ // We now know that all keys are present (everything is a package, file or both backend)
+ let mut package_managers = PackageManagerMap::new();
+ for entry in merged {
+ let (backend, packages, files) = match entry {
+ itertools::EitherOrBoth::Both(a, b) => (*a.0, Some(a.1), Some(b.1)),
+ itertools::EitherOrBoth::Left(a) => (*a.0, Some(a.1), None),
+ itertools::EitherOrBoth::Right(b) => (b.0, None, Some(b.1)),
+ };
+
+ let package_map = package_maps.get(&backend).cloned();
+ let pkg_mgr = PackageManager::new(
+ backend,
+ files.cloned(),
+ packages.cloned(),
+ package_map,
+ interner.clone(),
+ );
+ package_managers.insert(backend, pkg_mgr);
+ }
+ Self {
+ package_managers,
+ backend_with_files: file_backend_id,
+ }
+ }
+}
+
+impl PackageManagers {
+ /// Get an instance of a [`PackageManager`] by backend name
+ #[rune::function]
+ fn get(&self, name: &str) -> Option {
+ let backend = Backend::from_str(name).ok()?;
+ self.package_managers.get(&backend).cloned()
+ }
+
+ /// Get the package manager that handles files
+ #[rune::function]
+ fn files(&self) -> PackageManager {
+ self.package_managers
+ .get(&self.backend_with_files)
+ .expect("There should always be a files backend")
+ .clone()
+ }
+}
+
+/// Inner struct because rune function attributes don't want to play along.
+#[derive(Debug, Clone)]
+struct PackageManagerInner {
+ backend: Backend,
+ files: Option>,
+ packages: Option>,
+ package_map: Option>,
+ interner: Arc,
+}
+
+#[derive(Debug, Clone, Any)]
+#[rune(item = ::package_managers)]
+#[repr(transparent)]
+/// A package manager
+pub struct PackageManager {
+ inner: Shared,
+}
+
+// Rust API
+impl PackageManager {
+ /// Create a new package manager
+ pub fn new(
+ backend: Backend,
+ files: Option>,
+ packages: Option>,
+ package_map: Option>,
+ interner: Arc,
+ ) -> Self {
+ Self {
+ inner: Shared::new(PackageManagerInner {
+ backend,
+ files,
+ packages,
+ package_map,
+ interner,
+ })
+ .expect("Failed to create shared package manager"),
+ }
+ }
+
+ pub fn files(&self) -> Option> {
+ self.inner.borrow_ref().ok()?.files.clone()
+ }
+
+ pub fn packages(&self) -> Option> {
+ self.inner.borrow_ref().ok()?.packages.clone()
+ }
+
+ /// Get the original file contents of a package from Rust code
+ pub fn file_contents(&self, package: &str, path: &str) -> anyhow::Result> {
+ let queries: [_; 1] = [OriginalFileQuery {
+ package: package.into(),
+ path: path.into(),
+ }];
+ let guard = self.inner.borrow_ref()?;
+ let files = guard
+ .files
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("No files backend for {}", guard.backend))?;
+ let package_map = guard
+ .package_map
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("No package map for {}", guard.backend))?;
+ let results = files
+ .original_files(&queries, package_map, &guard.interner)
+ .with_context(|| format!("Failed original_file_contents({package}, {path})"))?;
+ if results.len() != 1 {
+ anyhow::bail!(
+ "Failed original_file_contents({package}, {path}): Got wrong number of results: {}",
+ results.len()
+ );
+ }
+ let result = results
+ .into_iter()
+ .next()
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "Failed original_file_contents({package}, {path}): Failed to extract result"
+ )
+ })?
+ .1;
+ Ok(result)
+ }
+}
+
+// Rune API
+impl PackageManager {
+ /// Get the original file contents of a package as a `Result`
+ #[rune::function]
+ fn original_file_contents(&self, package: &str, path: &str) -> anyhow::Result {
+ let result = self.file_contents(package, path)?;
+ Ok(Bytes::from_vec(result.try_into()?))
+ }
+}
+
+#[rune::module(::package_managers)]
+/// Interface to the package manager(s) in the system
+pub(crate) fn module() -> Result {
+ let mut m = Module::from_meta(self::module_meta)?;
+ m.ty::()?;
+ m.function_meta(PackageManager::original_file_contents)?;
+ m.ty::()?;
+ m.function_meta(PackageManagers::get)?;
+ m.function_meta(PackageManagers::files)?;
+ Ok(m)
+}
diff --git a/crates/konfigkoll_script/src/plugins/passwd.rs b/crates/konfigkoll_script/src/plugins/passwd.rs
new file mode 100644
index 00000000..a4f83b0b
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins/passwd.rs
@@ -0,0 +1,642 @@
+//! Helpers for working with /etc/passwd and /etc/groups (as well as shadow files)
+
+mod sysusers;
+
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fmt::Write,
+};
+
+use ahash::{AHashMap, AHashSet};
+use itertools::Itertools;
+use rune::{runtime::Function, Any, ContextError, Module, Value};
+use sysusers::{GroupId, UserId};
+use winnow::Parser;
+
+use crate::Commands;
+
+use super::package_managers::PackageManager;
+
+type Users = BTreeMap;
+type Groups = BTreeMap;
+
+/// A representation of the user and group databases
+///
+/// This can be used to handle `/etc/passwd` and related files.
+/// Typically you would:
+/// * Create an instance early in the main phase
+/// * Add things to it as needed (next to the associated packages)
+/// * Apply it at the end of the main phase
+///
+///
+/// A rough example:
+///
+/// ```rune
+/// // Mappings for the IDs that systemd auto-assigns inconsistently from computer to computer
+/// const USER_MAPPING = [("systemd-journald", 900), /* ... */]
+/// const GROUP_MAPPING = [("systemd-journald", 900), /* ... */]
+///
+/// pub async fn phase_main(props, cmds, package_managers) {
+/// let passwd = passwd::Passwd::new(USER_MAPPING, GROUP_MAPPING)?;
+///
+/// let files = package_managers.files();
+/// // These two files MUST come first as other files later on refer to them,
+/// // and we are not order independent (unlike the real sysusers.d).
+/// passwd.add_from_sysusers(files, "systemd", "/usr/lib/sysusers.d/basic.conf")?;
+/// passwd.add_from_sysusers(files, "filesystem", "/usr/lib/sysusers.d/arch.conf")?;
+///
+/// // Various other packages and other changes ...
+/// passwd.add_from_sysusers(files, "dbus", "/usr/lib/sysusers.d/dbus.conf")?;
+/// // ...
+///
+/// // Add human user
+/// let me = passwd::User::new(1000, "me", "me", "");
+/// me.shell = "/bin/zsh";
+/// me.home = "/home/me";
+/// passwd.add_user_with_group(me);
+/// passwd.add_user_to_groups("me", ["wheel", "optical", "uucp", "users"]);
+///
+/// // Don't store passwords in your git repo, load them from the system instead
+/// passwd.passwd_from_system(["me", "root"]);
+///
+/// // Give root a login shell, we don't want /usr/bin/nologin!
+/// passwd.update_user("root", |user| {
+/// user.shell = "/bin/zsh";
+/// user
+/// });
+///
+/// // Deal with the IDs not matching (because the mappings were created
+/// // before konfigkoll was in use for example)
+/// passwd.align_ids_with_system()?;
+///
+/// // Apply changes
+/// passwd.apply(cmds)?;
+/// }
+/// ```
+#[derive(Debug, Any)]
+#[rune(item = ::passwd)]
+struct Passwd {
+ users: Users,
+ groups: Groups,
+ user_ids: AHashMap,
+ group_ids: AHashMap,
+}
+
+/// Internal helper functions
+impl Passwd {
+ fn sanity_check(&self) -> anyhow::Result<()> {
+ // Check for duplicate IDs
+ {
+ let mut ids = BTreeSet::new();
+ for user in self.users.values() {
+ if !ids.insert(user.uid) {
+ return Err(anyhow::anyhow!(
+ "More than one user maps to UID: {}",
+ user.uid
+ ));
+ }
+ }
+ }
+ {
+ let mut ids = BTreeSet::new();
+ for group in self.groups.values() {
+ if !ids.insert(group.gid) {
+ return Err(anyhow::anyhow!(
+ "More than one group maps to GID: {}",
+ group.gid
+ ));
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+macro_rules! log_and_error {
+ ($($arg:tt)*) => {
+ tracing::error!($($arg)*);
+ return Err(anyhow::anyhow!($($arg)*));
+ };
+}
+
+/// Rune API
+impl Passwd {
+ /// Create a new Passwd instance
+ ///
+ /// # Arguments
+ /// * `user_ids` - A list of tuples of (username, uid) to use if sysusers files does not specify a UID
+ /// * `group_ids` - A list of tuples of (groupname, gid) to use if sysusers files does not specify a GID
+ #[rune::function(path = Self::new)]
+ fn new(user_ids: Vec<(String, u32)>, group_ids: Vec<(String, u32)>) -> anyhow::Result {
+ let num_uids = user_ids.len();
+ let num_gids = group_ids.len();
+ let uids: AHashMap = user_ids.into_iter().collect();
+ let gids: AHashMap = group_ids.into_iter().collect();
+ // Sanity check that there are no duplicates
+ if uids.len() != num_uids {
+ log_and_error!("Duplicate user names in user ID mapping");
+ }
+ if gids.len() != num_gids {
+ log_and_error!("Duplicate group names in group ID mapping");
+ }
+ // Sanity check that the mapped to values are unique
+ if uids.values().collect::>().len() != num_uids {
+ log_and_error!("Duplicate user IDs in user ID mapping");
+ }
+ if gids.values().collect::>().len() != num_gids {
+ log_and_error!("Duplicate group IDs in group ID mapping");
+ }
+ Ok(Self {
+ users: BTreeMap::new(),
+ groups: BTreeMap::new(),
+ user_ids: uids,
+ group_ids: gids,
+ })
+ }
+
+ /// Add a user to the passwd database
+ #[rune::function]
+ fn add_user(&mut self, user: User) {
+ self.users.insert(user.name.clone(), user);
+ }
+
+ /// Add a user to the passwd database (and add a matching group with the same ID)
+ #[rune::function]
+ fn add_user_with_group(&mut self, user: User) {
+ let group = Group {
+ name: user.group.clone(),
+ gid: user.uid,
+ members: Default::default(),
+ passwd: "!*".into(),
+ admins: Default::default(),
+ };
+ self.users.insert(user.name.clone(), user);
+ self.groups.insert(group.name.clone(), group);
+ }
+
+ /// Add a group to the passwd database
+ #[rune::function]
+ fn add_group(&mut self, group: Group) {
+ self.groups.insert(group.name.clone(), group);
+ }
+
+ /// Add an already added user to one or more already added groups
+ #[rune::function]
+ fn add_user_to_groups(&mut self, user: &str, groups: Vec) {
+ for group in groups {
+ if let Some(group) = self.groups.get_mut(&group) {
+ group.members.insert(user.into());
+ } else {
+ tracing::error!("Group {} not found", group);
+ }
+ }
+ }
+
+ /// Add an already added user to one or more already added groups
+ #[rune::function]
+ fn add_user_to_groups_as_admin(&mut self, user: &str, groups: Vec) {
+ for group in groups {
+ if let Some(group) = self.groups.get_mut(&group) {
+ group.admins.insert(user.into());
+ } else {
+ tracing::error!("Group {} not found", group);
+ }
+ }
+ }
+
+ #[rune::function]
+ fn update_user(&mut self, user: &str, func: &Function) {
+ // TODO: Get rid of expect
+ let user = self.users.get_mut(user).expect("User not found");
+ *user = func
+ .call::<_, User>((user.clone(),))
+ .expect("User update call failed");
+ }
+
+ #[rune::function]
+ fn update_group(&mut self, group: &str, func: &Function) {
+ let group = self.groups.get_mut(group).expect("Group not found");
+ *group = func
+ .call::<_, Group>((group.clone(),))
+ .expect("Group update call failed");
+ }
+
+ /// Read the passwd and group files from the system and update IDs to match the system (based on name)
+ #[rune::function]
+ fn align_ids_with_system(&mut self) -> anyhow::Result<()> {
+ self.sanity_check().inspect_err(|e| {
+ tracing::error!("Sanity check *before* aligning passwd IDs failed: {e}");
+ })?;
+ let passwd = std::fs::read_to_string("/etc/passwd")?;
+ for line in passwd.lines() {
+ let parts: Vec<_> = line.split(':').collect();
+ if parts.len() != 7 {
+ tracing::error!("Invalid line in /etc/passwd: {}", line);
+ continue;
+ }
+ let name = parts[0];
+ let uid: u32 = parts[2].parse()?;
+ if let Some(user) = self.users.get_mut(name) {
+ if user.uid != uid {
+ tracing::info!("Updating UID for {} from {} to {}", name, user.uid, uid);
+ user.uid = uid;
+ }
+ }
+ }
+
+ let group = std::fs::read_to_string("/etc/group")?;
+ for line in group.lines() {
+ let parts: Vec<_> = line.split(':').collect();
+ if parts.len() != 4 {
+ tracing::error!("Invalid line in /etc/group: {}", line);
+ continue;
+ }
+ let name = parts[0];
+ let gid: u32 = parts[2].parse()?;
+ if let Some(group) = self.groups.get_mut(name) {
+ if group.gid != gid {
+ tracing::info!("Updating GID for {} from {} to {}", name, group.gid, gid);
+ group.gid = gid;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Set user passwords to what they are set to on the system for the given users
+ #[rune::function]
+ // Allow because rune doesn't work without the owned vec
+ #[allow(clippy::needless_pass_by_value)]
+ fn passwd_from_system(&mut self, users: Vec) -> anyhow::Result<()> {
+ let shadow = std::fs::read_to_string("/etc/shadow")?;
+ for line in shadow.lines() {
+ let parts: Vec<_> = line.split(':').collect();
+ if parts.len() != 9 {
+ tracing::error!("Invalid line in /etc/shadow: {}", line);
+ continue;
+ }
+ let name = parts[0];
+ let passwd = parts[1];
+ if users.contains(&name.to_string()) {
+ if let Some(user) = self.users.get_mut(name) {
+ user.passwd = passwd.into();
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Add users and groups declared in a systemd sysusers file
+ ///
+ /// You need to provide a map of preferred IDs for any IDs not explicitly set in the sysusers file.
+ ///
+ /// # Arguments
+ /// * `package_manager` - The package manager to use for reading the sysusers file
+ /// * `config_file` - The path to the sysusers file
+ #[rune::function(keep)]
+ fn add_from_sysusers(
+ &mut self,
+ package_manager: &PackageManager,
+ package: &str,
+ config_file: &str,
+ ) -> anyhow::Result<()> {
+ let file_contents =
+ String::from_utf8(package_manager.file_contents(package, config_file)?)?;
+ let parsed = sysusers::parse_file
+ .parse(&file_contents)
+ .map_err(|error| sysusers::SysusersParseError::from_parse(&error, &file_contents))?;
+ for directive in parsed {
+ match directive {
+ sysusers::Directive::Comment => (),
+ sysusers::Directive::User(user) => {
+ let (uid, gid, group) = match user.id {
+ Some(UserId::Uid(uid)) => (uid, None, user.name.clone()),
+ Some(UserId::UidGroup(uid, group)) => (uid, None, group),
+ Some(UserId::UidGid(uid, gid)) => {
+ // Resolve gid to group name
+ let group = self.groups.values().find(|v| v.gid == gid);
+ let group_name = group.map(|g| g.name.as_str()).ok_or_else(|| {
+ anyhow::anyhow!("No group with GID {} for user {}", gid, user.name)
+ })?;
+ (uid, Some(gid), group_name.into())
+ }
+ Some(UserId::FromPath(_)) => {
+ return Err(anyhow::anyhow!("Cannot yet handle user IDs from path"))
+ }
+ None => {
+ let uid = self
+ .user_ids
+ .get(user.name.as_str())
+ .ok_or_else(|| anyhow::anyhow!("No ID for user {}", user.name))?;
+ (*uid, None, user.name.clone())
+ }
+ };
+ self.groups
+ .entry(group.clone().into())
+ .or_insert_with(|| Group {
+ name: group.clone().into(),
+ gid: gid.unwrap_or(uid),
+ members: Default::default(),
+ passwd: "!*".into(),
+ admins: Default::default(),
+ });
+ self.users
+ .entry(user.name.clone().into_string())
+ .or_insert_with(|| User {
+ uid,
+ name: user.name.into_string(),
+ group: group.into(),
+ gecos: user.gecos.map(Into::into).unwrap_or_default(),
+ home: user.home.map(Into::into).unwrap_or_else(|| "/".into()),
+ shell: user
+ .shell
+ .map(Into::into)
+ .unwrap_or_else(|| "/usr/bin/nologin".into()),
+ passwd: "!*".into(),
+ change: None,
+ min: None,
+ max: None,
+ warn: None,
+ inact: None,
+ expire: None,
+ });
+ }
+ sysusers::Directive::Group(group) => {
+ let gid = match group.id {
+ Some(GroupId::Gid(gid)) => gid,
+ Some(GroupId::FromPath(_)) => {
+ return Err(anyhow::anyhow!("Cannot yet handle group IDs from path"))
+ }
+ None => self
+ .group_ids
+ .get(group.name.as_str())
+ .copied()
+ .ok_or_else(|| anyhow::anyhow!("No ID for group {}", group.name))?,
+ };
+ self.groups
+ .entry(group.name.clone().into_string())
+ .or_insert_with(|| Group {
+ name: group.name.into_string(),
+ gid,
+ members: Default::default(),
+ passwd: "!*".into(),
+ admins: Default::default(),
+ });
+ }
+ sysusers::Directive::AddUserToGroup { user, group } => {
+ if let Some(group) = self.groups.get_mut(group.as_str()) {
+ group.members.insert(user.into_string());
+ } else {
+ tracing::error!("Group {} not found", group);
+ }
+ }
+ sysusers::Directive::SetRange(_, _) => (),
+ }
+ }
+ Ok(())
+ }
+
+ /// Apply to commands
+ #[rune::function]
+ fn apply(self, cmds: &mut Commands) -> anyhow::Result<()> {
+ self.sanity_check()
+ .inspect_err(|e| tracing::error!("Sanity check when applying passwd failed: {e}"))?;
+ let mut passwd = String::new();
+ let mut shadow = String::new();
+ let users = self.users.values().sorted().collect_vec();
+ let groups = self.groups.values().sorted().collect_vec();
+ for user in users {
+ writeln!(passwd, "{}", user.format_passwd(&self.groups))?;
+ writeln!(shadow, "{}", user.format_shadow())?;
+ }
+ let mut groups_contents = String::new();
+ let mut gshadow = String::new();
+ for group in groups {
+ writeln!(groups_contents, "{}", group.format_group())?;
+ writeln!(gshadow, "{}", group.format_gshadow())?;
+ }
+ for suffix in ["", "-"] {
+ cmds.write(&format!("/etc/passwd{suffix}"), passwd.as_bytes())?;
+ cmds.write(&format!("/etc/group{suffix}"), groups_contents.as_bytes())?;
+ let shadow_file = format!("/etc/shadow{suffix}");
+ cmds.write(&shadow_file, shadow.as_bytes())?;
+ let gshadow_file = format!("/etc/gshadow{suffix}");
+ cmds.write(&gshadow_file, gshadow.as_bytes())?;
+ if suffix == "-" {
+ // This is already set by package management for the main files
+ cmds.chmod(&shadow_file, Value::Integer(0o600))?;
+ cmds.chmod(&gshadow_file, Value::Integer(0o600))?;
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Represents a user
+#[derive(Any, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
+#[rune(item = ::passwd)]
+struct User {
+ // passwd info
+ /// User ID
+ #[rune(get, set)]
+ uid: u32,
+ /// Username
+ #[rune(get, set)]
+ name: String,
+ /// Group name
+ #[rune(get, set)]
+ group: String,
+ /// User information
+ #[rune(get, set)]
+ gecos: String,
+ /// Home directory
+ #[rune(get, set)]
+ home: String,
+ /// Path to shell
+ #[rune(get, set)]
+ shell: String,
+
+ // Shadow info
+ /// User password (probably hashed)
+ #[rune(get, set)]
+ passwd: String,
+
+ /// Last password change (days since epoch)
+ #[rune(get, set)]
+ change: Option,
+ /// Min password age (days)
+ #[rune(get, set)]
+ min: Option,
+ /// Max password age (days)
+ #[rune(get, set)]
+ max: Option,
+ /// Password warning period (days)
+ #[rune(get, set)]
+ warn: Option,
+ /// Password inactivity period (days)
+ #[rune(get, set)]
+ inact: Option,
+ /// Account expiration date (days since epoch)
+ #[rune(get, set)]
+ expire: Option,
+}
+
+/// Rust API
+impl User {
+ fn format_passwd(&self, groups: &Groups) -> String {
+ format!(
+ "{name}:x:{uid}:{gid}:{gecos}:{dir}:{shell}",
+ name = self.name,
+ uid = self.uid,
+ gid = groups.get(&self.group).map(|g| g.gid).unwrap_or(0),
+ gecos = self.gecos,
+ dir = self.home,
+ shell = self.shell,
+ )
+ }
+
+ fn format_shadow(&self) -> String {
+ let f64 = |v: Option| v.map(|v| format!("{v}")).unwrap_or("".into());
+ let f32 = |v: Option| v.map(|v| format!("{v}")).unwrap_or("".into());
+ format!(
+ "{name}:{passwd}:{change}:{min}:{max}:{warn}:{inact}:{expire}:",
+ name = self.name,
+ passwd = self.passwd,
+ change = f64(self.change),
+ min = f32(self.min),
+ max = f32(self.max),
+ warn = f32(self.warn),
+ inact = f32(self.inact),
+ expire = f64(self.expire),
+ )
+ }
+}
+
+/// Rune API
+impl User {
+ /// Create a new User
+ ///
+ /// This is optimised for a system user with sensible defaults.
+ ///
+ /// These defaults are:
+ /// * Home directory: `/`
+ /// * Shell: `/usr/bin/nologin`
+ /// * Password: `!*` (no login)
+ /// * No password expiration/age/warning/etc
+ /// * No account expiration
+ #[rune::function(path = Self::new)]
+ fn new(uid: u32, name: String, group: String, gecos: String) -> Self {
+ Self {
+ uid,
+ name,
+ group,
+ gecos,
+ home: "/".into(),
+ shell: "/usr/bin/nologin".into(),
+ passwd: "!*".into(),
+ change: None,
+ min: None,
+ max: None,
+ warn: None,
+ inact: None,
+ expire: None,
+ }
+ }
+}
+
+/// Represents a group
+#[derive(Any, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
+#[rune(item = ::passwd)]
+struct Group {
+ /// Group ID
+ #[rune(get, set)]
+ gid: u32,
+ /// Group name
+ #[rune(get, set)]
+ name: String,
+ /// Group members
+ members: BTreeSet,
+
+ // Shadow info
+ /// Password for group (probably hashed)
+ #[rune(get, set)]
+ passwd: String,
+ // Administrators
+ admins: BTreeSet,
+}
+
+/// Rust API
+impl Group {
+ fn format_group(&self) -> String {
+ let members = self
+ .members
+ .iter()
+ .map(String::as_str)
+ .collect::>()
+ .join(",");
+ format!("{name}:x:{gid}:{members}", name = self.name, gid = self.gid,)
+ }
+
+ fn format_gshadow(&self) -> String {
+ let members = self
+ .members
+ .iter()
+ .map(String::as_str)
+ .collect::>()
+ .join(",");
+ let admins = self
+ .admins
+ .iter()
+ .map(String::as_str)
+ .collect::>()
+ .join(",");
+ format!(
+ "{name}:{passwd}:{admins}:{members}",
+ name = self.name,
+ passwd = self.passwd,
+ members = members,
+ admins = admins,
+ )
+ }
+}
+
+/// Rune API
+impl Group {
+ /// Create a new group
+ #[rune::function(path = Self::new)]
+ fn new(name: String, gid: u32) -> Self {
+ Self {
+ name,
+ gid,
+ members: BTreeSet::new(),
+ passwd: "!*".into(),
+ admins: BTreeSet::new(),
+ }
+ }
+}
+
+#[rune::module(::passwd)]
+/// Utilities for patching file contents conveniently.
+pub(crate) fn module() -> Result {
+ let mut m = Module::from_meta(self::module_meta)?;
+ m.ty::()?;
+ m.ty::()?;
+ m.ty::()?;
+
+ m.function_meta(Passwd::new)?;
+ m.function_meta(Passwd::add_user)?;
+ m.function_meta(Passwd::add_group)?;
+ m.function_meta(Passwd::add_user_with_group)?;
+ m.function_meta(Passwd::add_user_to_groups)?;
+ m.function_meta(Passwd::add_user_to_groups_as_admin)?;
+ m.function_meta(Passwd::add_from_sysusers__meta)?;
+ m.function_meta(Passwd::passwd_from_system)?;
+ m.function_meta(Passwd::align_ids_with_system)?;
+ m.function_meta(Passwd::update_group)?;
+ m.function_meta(Passwd::update_user)?;
+ m.function_meta(Passwd::apply)?;
+ m.function_meta(User::new)?;
+ m.function_meta(Group::new)?;
+
+ Ok(m)
+}
diff --git a/crates/konfigkoll_script/src/plugins/passwd/sysusers.rs b/crates/konfigkoll_script/src/plugins/passwd/sysusers.rs
new file mode 100644
index 00000000..029d88d1
--- /dev/null
+++ b/crates/konfigkoll_script/src/plugins/passwd/sysusers.rs
@@ -0,0 +1,416 @@
+//! Parser for systemd sysusers.d files.
+
+use compact_str::CompactString;
+use winnow::ascii::{dec_uint, escaped_transform, newline, space1};
+use winnow::combinator::{alt, delimited, opt, separated, trace};
+use winnow::error::{ContextError, StrContext};
+use winnow::stream::Accumulate;
+use winnow::token::take_till;
+use winnow::PResult;
+use winnow::Parser;
+
+/// Sub-error type for the first splitting layer
+#[derive(Debug, PartialEq)]
+pub(super) struct SysusersParseError {
+ message: String,
+ pos: usize,
+ input: String,
+}
+
+impl SysusersParseError {
+ pub(super) fn from_parse<'input>(
+ error: &winnow::error::ParseError<&'input str, ContextError>,
+ input: &'input str,
+ ) -> Self {
+ let message = error.inner().to_string();
+ let input = input.to_owned();
+ Self {
+ message,
+ pos: error.offset(),
+ input,
+ }
+ }
+}
+
+impl std::fmt::Display for SysusersParseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let pos = self.pos;
+ let input = &self.input;
+ let message = &self.message;
+ write!(
+ f,
+ "Error at position {}: {}\n{}\n{}^",
+ pos,
+ message,
+ &input[..pos],
+ " ".repeat(pos)
+ )
+ }
+}
+
+impl std::error::Error for SysusersParseError {}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum Directive {
+ Comment,
+ User(User),
+ Group(Group),
+ AddUserToGroup {
+ user: CompactString,
+ group: CompactString,
+ },
+ SetRange(u32, u32),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) struct User {
+ pub name: CompactString,
+ pub id: Option,
+ pub gecos: Option,
+ pub home: Option,
+ pub shell: Option,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum UserId {
+ Uid(u32),
+ UidGid(u32, u32),
+ UidGroup(u32, CompactString),
+ FromPath(CompactString),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum GroupId {
+ Gid(u32),
+ FromPath(CompactString),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) struct Group {
+ pub name: CompactString,
+ pub id: Option,
+}
+
+/// Top level parser
+pub(super) fn parse_file(i: &mut &str) -> PResult> {
+ let alternatives = (
+ comment
+ .map(|_| Directive::Comment)
+ .context(StrContext::Label("comment")),
+ user.context(StrContext::Label("user")),
+ group.context(StrContext::Label("group")),
+ add_to_group.context(StrContext::Label("add_to_group")),
+ set_range.context(StrContext::Label("set_range")),
+ "".map(|_| Directive::Comment)
+ .context(StrContext::Label("whitespace")), // Blank lines
+ );
+ (separated(0.., alt(alternatives), newline), opt(newline))
+ .map(|(val, _)| val)
+ .parse_next(i)
+}
+
+/// Helper to `directive` to flatten the optional tuple
+fn flattener(e: Option<(&str, Option)>) -> Option {
+ e.and_then(|(_, arg)| arg)
+}
+
+fn user(i: &mut &str) -> PResult {
+ let entry_type = 'u';
+ let user_name = any_string.context(StrContext::Label("user_name"));
+ let id = user_id_parser.context(StrContext::Label("id"));
+ let gecos = optional_string.context(StrContext::Label("gecos"));
+ let home_dir = optional_string.context(StrContext::Label("home"));
+ let shell = optional_string.context(StrContext::Label("shell"));
+
+ let mut parser = (
+ entry_type,
+ space1,
+ user_name,
+ opt((space1, id)).map(flattener),
+ opt((space1, gecos)).map(flattener),
+ opt((space1, home_dir)).map(flattener),
+ opt((space1, shell)).map(flattener),
+ )
+ .map(|(_, _, name, id, gecos, home, shell)| {
+ Directive::User(User {
+ name,
+ id,
+ gecos,
+ home,
+ shell,
+ })
+ });
+ parser.parse_next(i)
+}
+
+fn group(i: &mut &str) -> PResult {
+ let entry_type = 'g';
+ let path = any_string.context(StrContext::Label("group_name"));
+ let id = group_id_parser.context(StrContext::Label("id"));
+
+ let mut parser = (
+ entry_type,
+ space1,
+ path,
+ opt((space1, id)).map(flattener),
+ opt((space1, '-')),
+ opt((space1, '-')),
+ )
+ .map(|(_, _, name, id, _, _)| Directive::Group(Group { name, id }));
+ parser.parse_next(i)
+}
+
+fn add_to_group(i: &mut &str) -> PResult {
+ let entry_type = 'm';
+ let user = any_string.context(StrContext::Label("user_name"));
+ let group = any_string.context(StrContext::Label("group_name"));
+
+ let mut parser = (entry_type, space1, user, space1, group)
+ .map(|(_, _, user, _, group)| Directive::AddUserToGroup { user, group });
+ parser.parse_next(i)
+}
+
+fn set_range(i: &mut &str) -> PResult {
+ let entry_type = 'r';
+ let name = '-';
+ let range = range_parser.context(StrContext::Label("range"));
+
+ let mut parser = (entry_type, space1, name, space1, range)
+ .map(|(_, _, _, _, range)| Directive::SetRange(range.0, range.1));
+ parser.parse_next(i)
+}
+
+fn user_id_parser(i: &mut &str) -> PResult