diff --git a/.github/workflows/roadmap-sync.yml b/.github/workflows/roadmap-sync.yml index 3ac4f17..35eafdc 100644 --- a/.github/workflows/roadmap-sync.yml +++ b/.github/workflows/roadmap-sync.yml @@ -15,12 +15,12 @@ jobs: if: "!contains(github.event.head_commit.message, '[roadmap-sync]')" steps: - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index f986e58..6cb4e49 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -2,6 +2,7 @@ name: Rust Tests on: push: + branches: [main] pull_request: workflow_dispatch: @@ -14,10 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install stable Rust toolchain - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable - name: Run tests run: cargo test --locked diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 295a4b8..a468c1d 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -2,6 +2,7 @@ name: Security Audit on: push: + branches: [main] pull_request: schedule: # Weekly, Mondays at 06:17 UTC (off the top of the hour to reduce runner contention). @@ -16,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install stable Rust toolchain - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable - name: Install cargo-audit run: cargo install cargo-audit --version 0.22.2 --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4b3a3..47610f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 2026-11-03 — v1.0.2 + +### Security + +- Updated `wasmtime` and `wasmtime-wasi` to 50.0.1 and `zstd`/`zstd-safe` to 0.13.4/7.4.0. This closes RUSTSEC-2026-0391, RUSTSEC-2026-0395 through RUSTSEC-2026-0406, RUSTSEC-2026-0420 and RUSTSEC-2026-0422 through RUSTSEC-2026-0424. `cargo audit --deny warnings` reports no vulnerabilities and no warnings. +- **Binaries built from v1.0.1 remain affected by these advisories; upgrade to v1.0.2.** +- The wasm runtime moves from the 48.x line to 50.0.1. The guest ABI and WIT worlds are unchanged; the wasm end-to-end suite is part of the release gate. + +### Dependencies + +- Updated `sysinfo` from 0.39.6 to 0.40.1 and adapted the fallible constructors and static getters (`System::new`, `new_all`, `new_with_specifics`, `name`, `os_version`, `kernel_version`, `host_name`, `uptime`) plus the `OsString` network interface names. No user-visible output changes. +- Migrated from `ureq` 2.12 to 3.5.1. The updater, public IP probe and wasm HTTP host now use `Agent::config_builder`, `Error::StatusCode`, `Body` readers and request-level timeouts. A local HTTP success test covers the wasm host path. No user-facing behavior change is intended. +- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`. +- Updated the GitHub Actions group: `actions/checkout` to 7.0.1, `actions/setup-python` to 7.1.0 and the pinned `dtolnay/rust-toolchain` commit. All actions remain pinned by commit SHA. + +### Package counts + +- Added `xpm`, `xp` and `xpm-aur` labels to the Linux package breakdown. They read only local installation state: xpm's database (`/var/lib/xpm/local`) and pacman's local database (`/var/lib/pacman/local`), which xpm mirrors its transactions into, so packages from both managers are counted once. `xp` is the installed subset from the native `x` repository; `xpm` is the shared total. The xpm-aur sidecar is intersected with that set so stale records are ignored. When the `pacman` binary is unavailable, the local database is counted directly instead of showing `unknown`. Remote catalogs are never queried and existing managers are unchanged. + +### CLI + +- Added `xfetch --list-layouts`, which prints every name accepted by `--layout`. Corrected the `--layout` help: `--gen-config` defaults to the `section` layout, and unknown names keep that fallback. + +### Addon tooling + +- `xfetch plugin`, `xfetch effects` and `xfetch extension` installs now print the configuration snippet required to enable the installed addon, along with its declared capabilities and limits. +- Added `list --verbose` and `info ` for plugins, effects and extensions. They report the manifest kind, runtime, version, capabilities, limits and whether the addon is enabled. +- Direct wasm files and URLs now accept `--name` and `--manifest` on install; directory and repository installs keep using their own name and manifest. +- Added `search` (alias `available`) for the remote plugin, effect and extension catalogs, with optional `--json` output and `XFETCH_PLUGIN_INDEX`/`XFETCH_EFFECT_INDEX`/`XFETCH_EXTENSION_INDEX` overrides. +- Added command aliases `plugins`, `effect`, and `extensions` for the existing command families. + +### Release + +- Bumped the crate version to 1.0.2 and updated the installer and verification examples to 1.0.2. The signature policy threshold remains v1.0.1. +- CI push triggers are limited to `main`; release branches are validated through their pull requests, avoiding duplicate runs. + ## 2026-10-24 — v1.0.1 ### Security diff --git a/Cargo.lock b/Cargo.lock index 5f0aa39..24c81f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,12 +23,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - [[package]] name = "ansi_colours" version = "1.2.3" @@ -180,29 +174,11 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cap-primitives" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" -dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes 3.0.1", - "ipnet", - "maybe-owned", - "rustix", - "rustix-linux-procfs", - "windows-sys 0.61.2", - "winx", -] - [[package]] name = "cc" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", "jobserver", @@ -350,27 +326,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6071a4f1df4837aa03d1e4d53067784acafe270ffc7dad66a7f39832204da3f" +checksum = "80552083a2db143c5d951debe9f59a7d9f22084c70abe450fed5220060fa26c3" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3b92a9cc90388fe70e63640af30b734387449445aef0176ab2028e9892d70e" +checksum = "7cf84b7e5f0aa42cb0d55cabf01c520fdb70a50d8b572a841ee875083589d643" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f45c85646c4425cc85b868effaf20c302b437a3fb79bb27a64513dd522f07411" +checksum = "81d47ec2358cdbace0520ac212c003656b529f0c60e43fbbe9a331632cd9e8fc" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -378,9 +354,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f56b5c7d564cfa03aa269a6b98c916cab4d545196a391d31403a23ef39333a" +checksum = "eebb94b54db96b26e387bc8c698823d83d9254d551a6ed846a60cebf62ed7135" dependencies = [ "serde", "serde_derive", @@ -389,9 +365,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c073641ce6e382814a8220331cc1ea2c782a9ea5d5da6d0f7f7c0541192693e" +checksum = "be81b89cb7b186a26cf2014c312aeb17737da988b4444d16a0cb3424d637d44e" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -420,9 +396,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db226fafea5194921de10dccf35ac2863500313990f770cbe8b26c51eebec4d4" +checksum = "cb1b64a509c3814932fb9845ce8da835cd10d62c6c56dacfdcd9af389d3bf313" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -433,24 +409,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8ac46b2c8e328ca979810426ea635ef90ecc8c54fd3296bde31ab470a48a1d" +checksum = "e667a0e9ed3bbbed0e7e52cd2dea85a645745b39f547097e2fca0bf317cac9df" [[package]] name = "cranelift-control" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754e0ab97915209ea6ce6549f6443b018e1338142ada4c856cec213ee82e4b8" +checksum = "a5fa7d5123705226a368b6b1c7b10f23036c35e8041ffdd5f6505790f5c31404" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774d25e36df18696f63a284e9c4ea43455e2ebcca6308e0e02301f18c8e777e2" +checksum = "a1bffb83ac1d7052691d1710ca8eb334d4fcaad363e589f2b89d38d97bda5776" dependencies = [ "cranelift-bitset", "serde", @@ -460,9 +436,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cfcb31405e2de2a48e5a6de8e07a3d8b77f35caa3e944d627780734b36c138" +checksum = "1bc727c856bf57eae76c72ac4f9b1eaf49947a2b02f699798262fc8399557b7a" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -473,32 +449,33 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5a26df22db546ab92be6feaf21835ae91082af0a32bbb398b277df3765412f" +checksum = "a92e743f35915d55cd3d3178aab483661f78c2f9274aa704f29be69e3789c5bd" [[package]] name = "cranelift-native" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f258c08e590f90dcaf9904d25ba142ee2980c3aaeac4bb45f1452b1a407c2a" +checksum = "127c50e5e2b2b97116d4e2c079db6ce29b07303cd1ab48a8bb4c2abbf16a7a68" dependencies = [ "cranelift-codegen", "libc", + "mach2", "target-lexicon", ] [[package]] name = "cranelift-srcgen" -version = "0.135.7" +version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67055b4f315e8f6af3e8eccec74ad38f7407f0fd854adbd49b4b3b27bdb2e3be" +checksum = "6f34b169b8cbbaed2b4995205f1d02d7eac4b2771df5479255fe5483e1334a39" [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -721,9 +698,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -818,17 +795,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-set-times" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" -dependencies = [ - "io-lifetimes 2.0.4", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "futures" version = "0.3.34" @@ -982,6 +948,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -1142,34 +1124,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "io-extras" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" -dependencies = [ - "io-lifetimes 3.0.1", - "windows-sys 0.60.2", -] - -[[package]] -name = "io-lifetimes" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" - -[[package]] -name = "io-lifetimes" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" - -[[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1269,9 +1223,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "mach2" @@ -1279,12 +1233,6 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "memchr" version = "2.8.3" @@ -1425,9 +1373,9 @@ dependencies = [ [[package]] name = "object" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" dependencies = [ "crc32fast", "hashbrown 0.17.1", @@ -1539,9 +1487,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8187c8e7db87d085865fc106502e7b34d5c25f8ce4c272945b21503371f6a0" +checksum = "64c66a82a3d48db9b7c2ba1307e06fcb81a35ba82952f58bc1bfb218321f7c0b" dependencies = [ "cranelift-bitset", "log", @@ -1551,13 +1499,13 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f98ce0e381d9e45e63ea1d20bba3cf131797086077f43af9203f1fb7de0ed6b" +checksum = "138a9a5997d9cba5e54c7c3b836d7164f2bfe0852c7e98d5835e1708d6efdf36" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1715,9 +1663,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -1994,9 +1942,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.6" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8" dependencies = [ "libc", "memchr", @@ -2126,9 +2074,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -2136,14 +2084,14 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -2154,7 +2102,7 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -2214,9 +2162,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] name = "untrusted" @@ -2226,18 +2174,31 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "log", - "once_cell", + "percent-encoding", "rustls", "rustls-pki-types", - "url", - "webpki-roots 0.26.11", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", ] [[package]] @@ -2252,11 +2213,20 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] [[package]] name = "utf8parse" @@ -2264,6 +2234,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "version_check" version = "0.9.5" @@ -2293,41 +2269,41 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-encoder" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebaca0b540bdb15d195ff34fb23074255948708b8916a11a339ba4513dc61906" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" dependencies = [ "leb128fmt", - "wasmparser 0.254.2", + "wasmparser 0.261.0", ] [[package]] name = "wasm-encoder" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f" dependencies = [ "leb128fmt", - "wasmparser 0.262.0", + "wasmparser 0.263.0", ] [[package]] name = "wasm-metadata" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1880dd9a848aa68f387966256c34ad49abb4607e3cfa35720d2723d0591cd3" +checksum = "b88de21560ecd49deb8d8f1616b9b695f66fc312c5eca503f43accdc5795339e" dependencies = [ "anyhow", "indexmap", - "wasm-encoder 0.254.2", - "wasmparser 0.254.2", + "wasm-encoder 0.261.0", + "wasmparser 0.261.0", ] [[package]] name = "wasmparser" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" dependencies = [ "bitflags", "hashbrown 0.17.1", @@ -2338,9 +2314,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb" dependencies = [ "bitflags", "indexmap", @@ -2349,20 +2325,20 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b03c8f270f3f384f70f42869b5ff7f6253fe4f0aa8590f7edd332c1943e5f51" +checksum = "86a21c9e3325655ba02de3e645bc8357a8b3651a2e3ada25b6dbc37560deb9b7" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.254.2", + "wasmparser 0.261.0", ] [[package]] name = "wasmtime" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12300d040e8bea7e7ccde75b9919d27ebba47a217a64b09ba058ae8d3b4290b6" +checksum = "fdfacce893df54d319b29720e79edb001cd1b48faaf78fdb3b9c608eaeeea29f" dependencies = [ "addr2line", "async-trait", @@ -2386,7 +2362,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.254.2", + "wasmparser 0.261.0", "wasmtime-environ", "wasmtime-internal-cache", "wasmtime-internal-component-macro", @@ -2404,9 +2380,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805d6554eb6dd6e62891061a31383a89d69fd891f785e5ae68f0835a554b7a2a" +checksum = "f79df81f2a5d7a1cb8b9eb35b4a2d7a8cb8e948bd3e5a1f79036c0094879275a" dependencies = [ "anyhow", "cpp_demangle", @@ -2423,11 +2399,10 @@ dependencies = [ "semver", "serde", "serde_derive", - "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.254.2", - "wasmparser 0.254.2", + "wasm-encoder 0.261.0", + "wasmparser 0.261.0", "wasmprinter", "wasmtime-internal-component-util", "wasmtime-internal-core", @@ -2435,9 +2410,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11ca8945aa341f944558622db59845dffa1435971f318d5fce2f2d9765a69b0" +checksum = "ee31304d6e33265ac43eb24966801900a28a8161c10e035f5582db3bccf2a7e6" dependencies = [ "base64 0.22.1", "directories-next", @@ -2455,14 +2430,14 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b832b80853365d9d3a741681044554be291a4f977aae074b318b55c7a4835f83" +checksum = "61479c89e12c716cc7a962e68574c01f3cf8bfd27fbeb6fc477b27b5096b8350" dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", "wit-parser", @@ -2470,15 +2445,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8a11aaf68d8945991429d45950f32490a30ddcdcdb56629816cb41a255d8fe" +checksum = "33c2836b127721e0abdc3490545cdc6d5269d1050a19a4057909901c16e1b781" [[package]] name = "wasmtime-internal-core" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea266d6cfb31c324c075c8432386654bcbb1559aa8d29be9d47eacf7dcc41012" +checksum = "e765a08b721bf2b2e5cbd3e9365a71b497d57fc5e8b2cf73ddf12d85d502d7fa" dependencies = [ "hashbrown 0.17.1", "libm", @@ -2487,9 +2462,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d427759553b07c296c8ed7bff8ac930a94b9ba99e6a79243ea79bcc51aa58d96" +checksum = "25e8f190dd62c021d278ab0bd4afab06426844831d6a35dc369835754ad997d4" dependencies = [ "cranelift-codegen", "cranelift-control", @@ -2504,7 +2479,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.21", - "wasmparser 0.254.2", + "wasmparser 0.261.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -2513,9 +2488,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54c8c5b02ea985149a90f917e1761af99734c6c27e6d7fdd5b6f69fc1db3950" +checksum = "6326d47c759c0478489a034f1282a6f516ce97594f9af785539697d03add17ec" dependencies = [ "cc", "libc", @@ -2527,9 +2502,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72bb10c94724f072b6eca515085033e61da9efae65d054545126df37a15530c3" +checksum = "9b78d046468d9aaf81e97954165369693084f43e313daad47f959b1169d596d3" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -2537,9 +2512,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b781a27e8958fd07fd3135c0be216f77301efecd15cbf4f14ff210cdad5f169" +checksum = "ac3e5f3cb3fdc12489163dc60741696dddb32c9ed0662ff59c8dfb177ffd7a07" dependencies = [ "libc", "wasmtime-internal-core", @@ -2548,9 +2523,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121188de1b2741b8e35530a9b0c8adf525b3aa5913a2ca3d4522509cfbc0494a" +checksum = "bbf9357720c1477c43f88dace11923737e8aa87abe5f46f90e23c93a2a0fb3fe" dependencies = [ "cranelift-codegen", "log", @@ -2560,20 +2535,20 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "933f1990e4e5177547a4abd51dcfa5cb7616fe42b9fb6f0f2b7423627758223c" +checksum = "ce7c663ad018cc86ce824e21b35b8bed9877ee9ca6af8ef8672e28852450392e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "wasmtime-internal-wit-bindgen" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ebd61d2581d86d65413517f3a41294a9ad105da26f0dc7039912a0d97e1ed" +checksum = "6df6397ebf5957c9d9c6001b7d9cd1586113f3556e7d1f9e0457c7166cf0a1da" dependencies = [ "anyhow", "bitflags", @@ -2585,14 +2560,13 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ef0d3ec88f9406bc5e498848489c49ff515577500acc9f03fa93d0883dce435" +checksum = "fdc6ef7dfa261eda94e66b547cb93cced2d25eb7ea19f6621a5dfc42cb39fcae" dependencies = [ "async-trait", "bitflags", "bytes", - "cap-primitives", "futures", "rand", "rustix", @@ -2605,14 +2579,13 @@ dependencies = [ "wasmtime-wasi-io", "wiggle", "windows-sys 0.61.2", - "winx", ] [[package]] name = "wasmtime-wasi-io" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7dfc924e222e15455bd6d445ff7065a4595fffc7698c7bbc01acc0ed6c0421" +checksum = "9828eddc4b35d0d91a2f7ea3ff7c72b67808ece7bb2663eed0b17e480285897e" dependencies = [ "async-trait", "bytes", @@ -2632,33 +2605,24 @@ dependencies = [ [[package]] name = "wast" -version = "262.0.0" +version = "263.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.262.0", + "wasm-encoder 0.263.0", ] [[package]] name = "wat" -version = "1.262.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" -dependencies = [ - "wast 262.0.0", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" +version = "1.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7" dependencies = [ - "webpki-roots 1.0.11", + "wast 263.0.0", ] [[package]] @@ -2678,9 +2642,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wiggle" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f25221922f042ed78c7857a29f2e54d45d02a73891bcc3a046f2c3c0400cfd3" +checksum = "8c29596a5aa1a8356a7b45d099f0317dddac51e9a7424ead062cd84d13c64064" dependencies = [ "bitflags", "thiserror 2.0.21", @@ -2692,27 +2656,27 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9ad83cd72263fd94189bdbf3bf37dc131f6a293ff639cebfbb00ed3f21f05f2" +checksum = "ed5dfb60b3495abb891675ab46933e5168140d87eb3a21a412360daa47c52896" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasmtime-environ", "witx", ] [[package]] name = "wiggle-macro" -version = "48.0.7" +version = "50.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96820f65b0f61bad3e6dd073f670db9a6da537dc71f848fee715f692b19dfd9a" +checksum = "a99a153f98fec1f3c6d33d7f22ed294686ad817be10b7890c669a5d63c9b40e4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wiggle-generate", ] @@ -2854,25 +2818,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2890,31 +2836,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2932,123 +2861,59 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -[[package]] -name = "winx" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags", - "windows-sys 0.59.0", -] - [[package]] name = "wit-component" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c96b3cea6838dbea55173baa56991fcb087896c321b0a770db201e5a0bd3506" +checksum = "2818766842bd2321074c348fb5c15d8fdb105a9eb78374e6d77c7d4127e1f2f9" dependencies = [ "anyhow", "bitflags", @@ -3057,17 +2922,17 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.254.2", + "wasm-encoder 0.261.0", "wasm-metadata", - "wasmparser 0.254.2", + "wasmparser 0.261.0", "wit-parser", ] [[package]] name = "wit-parser" -version = "0.254.2" +version = "0.261.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22672d61bfb9df46a16b46ce38522293aee1cc4749fc84a3464ce4083b3d5fd6" +checksum = "962e43c4db2da607b50839a531e5b63473268b40efdf8028d5c54b572ca4f6f6" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -3079,7 +2944,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-ident", - "wasmparser 0.254.2", + "wasmparser 0.261.0", ] [[package]] @@ -3102,9 +2967,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xattr" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" dependencies = [ "libc", "rustix", @@ -3112,7 +2977,7 @@ dependencies = [ [[package]] name = "xfetch-cli" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "base64 0.23.1", @@ -3289,18 +3154,18 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "cdb02c3132d251ab1606e9fc913a7da6c6ed08cc263df15edf082e1dc497d157" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "1c8ea04668f5de8d8d51051714ba560c9536519cadcdb8829bba4fe2ad159b03" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index c4e4443..02615f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xfetch-cli" -version = "1.0.1" +version = "1.0.2" edition = "2024" license = "MIT" description = "XFetch — a fast, customizable system information fetch tool for the terminal" @@ -23,20 +23,20 @@ flate2 = { version = "1", optional = true } semver = { version = "1", optional = true } sha2 = "0.11" tar = { version = "0.4", optional = true } -clap = { version = "4.5.54", features = ["derive"] } -console = "0.16.2" +clap = { version = "4.6.7", features = ["derive"] } +console = "0.16.6" crossterm = "0.29.0" dirs = "7.0.0" -image = { version = "0.25.9", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] } json_comments = "0.2.2" libc = "0.2" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.148" -sysinfo = "0.39.6" -ureq = { version = "2.12", features = ["tls"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sysinfo = "0.40.1" +ureq = "3.5.1" url = { version = "2", optional = true } viuer = { version = "0.11.0", default-features = false } -wasmtime = { version = "48", optional = true, default-features = false, features = [ +wasmtime = { version = "50.0.1", optional = true, default-features = false, features = [ "std", "runtime", "cranelift", @@ -45,7 +45,7 @@ wasmtime = { version = "48", optional = true, default-features = false, features "cache", "parallel-compilation", ] } -wasmtime-wasi = { version = "48", optional = true, default-features = false, features = [ +wasmtime-wasi = { version = "50.0.1", optional = true, default-features = false, features = [ "p1", "p2", ] } @@ -69,4 +69,4 @@ windows-sys = { version = "0.61", features = [ ] } [dev-dependencies] -wat = "1" +wat = "1.263.0" diff --git a/README.md b/README.md index f08e6f1..4a19391 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,13 @@ xfetch --config path/to/config.jsonc xfetch --gen-config # generate a starter config xfetch --clean-cache # clear the module cache xfetch plugin install # install a plugin +xfetch plugin search # search the plugin catalog +xfetch plugin available # list the plugin catalog xfetch extension install # install an extension +xfetch extension search # search the extension catalog xfetch theme list # list themes xfetch effects install # install an intro effect (e.g. decrypt) +xfetch effects search # search the effect catalog xfetch update --check # check for a newer release xfetch update # update to the latest release ``` diff --git a/SECURITY.md b/SECURITY.md index 37bd597..18c55a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,7 +48,7 @@ Signing uses GitHub Actions OIDC; there is no long-lived release key. ### Verify the checksums signature ```bash -VERSION=1.0.1 # the release you downloaded +VERSION=1.0.2 # the release you downloaded BASE="https://github.com/xfetch-cli/xfetch/releases/download/v${VERSION}" curl -fsSLO "${BASE}/SHA256SUMS" @@ -73,7 +73,7 @@ exists, `openssl dgst -sha256 ` prints the digest to compare by hand. ### Verify the build provenance attestation ```bash -VERSION=1.0.1 +VERSION=1.0.2 gh attestation verify "xfetch-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ --repo xfetch-cli/xfetch ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4fc241d..ab6d858 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -42,6 +42,7 @@ xfetch --daemon-live-stopStop the running live stats daemon. xfetch --daemon-live-reloadForce hot reload in the live stats daemon (same as "daemon_live_reload": true). xfetch --gen-configGenerate a starter config at the default location. See GEN_CONFIG.md. + xfetch --list-layoutsPrint the layout names accepted by --layout. xfetch --clean-cacheClear the module cache. xfetch --benchmarkPrint benchmarking info for the info gathering step. xfetch plugin install|list|remove <name>Manage plugins. See PLUGINS.md. @@ -147,12 +148,24 @@ packages:pacmanArch (pacman) - Official repository packages only (pacman -Qn). + Official repository packages only (pacman -Qn); falls back to the local database when the binary is unavailable. packages:aurArch (pacman) AUR and manually installed packages (pacman -Qm). + + packages:xpmX Linux (xpm) + Installed packages visible to xpm (its database plus the pacman local database it shares). + + + packages:xpX Linux (xpm) + Native X packages installed from the x repository (subset of xpm). + + + packages:xpm-aurX Linux (xpm-aur) + Installed AUR packages tracked by xpm-aur. + packages:dpkgDebian, Ubuntu apt packages. diff --git a/docs/EFFECTS.md b/docs/EFFECTS.md index a526e7b..443150a 100644 --- a/docs/EFFECTS.md +++ b/docs/EFFECTS.md @@ -24,9 +24,30 @@

Manage installed effects:

-
xfetch effects list      # list installed effects
+
xfetch effects list
+xfetch effects list --verbose
+xfetch effects info decrypt
+xfetch effects search <query>
+xfetch effects available
 xfetch effects remove decrypt
+

+ search reads the effects catalog (index.json), + filters by name, description or runtime, and prints the matching install + command. available lists the whole catalog, --json + emits JSON, and XFETCH_EFFECT_INDEX overrides the catalog URL. +

+ +

+ After a successful install, xfetch prints the "effects" config + entry, the declared capabilities and the manifest limits. + list --verbose adds the manifest kind, runtime, version and + whether the effect is enabled; info prints the details for one + effect. Direct wasm files and URLs accept --name and + --manifest; directory and repository installs use their own + name and manifest. +

+

Binaries are stored in ~/.config/xfetch/effects/. Use --repo <url> (or the XFETCH_EFFECT_REPO env var) to install from a fork or mirror.

diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index b8b3a93..604a791 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -21,11 +21,32 @@
xfetch extension install /path/to/xfetch-extension-name
 xfetch extension install my-extension --repo https://github.com/user/extensions.git
-

List and remove installed extensions:

+

List, inspect and remove installed extensions:

xfetch extension list
+xfetch extension list --verbose
+xfetch extension info <name>
+xfetch extension search <query>
+xfetch extension available
 xfetch extension remove <name>
+

+ search reads the extensions catalog (index.json), + filters by name, description or runtime, and prints the matching install + command. available lists the whole catalog, --json + emits JSON, and XFETCH_EXTENSION_INDEX overrides the catalog URL. +

+ +

+ After a successful install, xfetch prints the + "config_providers" entry, the declared capabilities and the + manifest limits. list --verbose adds the manifest kind, runtime, + version and whether the extension is enabled; info prints the + details for one extension. Direct wasm files and URLs accept + --name and --manifest; directory and repository + installs use their own name and manifest. +

+

Installed binaries live in ~/.config/xfetch/extensions/ and are named xfetch-extension-<name> (.exe on Windows).

diff --git a/docs/GEN_CONFIG.md b/docs/GEN_CONFIG.md index 12a7de8..915d7a9 100644 --- a/docs/GEN_CONFIG.md +++ b/docs/GEN_CONFIG.md @@ -43,7 +43,7 @@
{
     "ascii": "/home/jan/.config/xfetch/logos/ubuntu.txt",
-    "layout": "pacman",
+    "layout": "section",
     // ...
 }
@@ -92,7 +92,8 @@ xfetch --gen-config --logo macos-ventura

The template ships with the section layout (grouped Hardware/Software/Session modules). Use --layout to generate it with any of the built-in layouts (see - LAYOUTS.md): + LAYOUTS.md). Run xfetch --list-layouts to print every + accepted name:

xfetch --gen-config --layout pacman
@@ -108,7 +109,7 @@ xfetch --gen-config --layout compact
  • horizontal, bottom (vertical layouts)
  • -

    An unknown layout name warns and keeps pacman.

    +

    An unknown layout name warns and keeps section.

    Combined Example

    diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index c23cb46..d499e3e 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -313,7 +313,7 @@ makepkg -si and gh):

    -
    VERSION=1.0.1
    +
    VERSION=1.0.2
     ASSET="xfetch-${VERSION}-x86_64-unknown-linux-gnu.tar.gz"
     BASE="https://github.com/xfetch-cli/xfetch/releases/download/v${VERSION}"
     
    diff --git a/docs/LAYOUTS.md b/docs/LAYOUTS.md
    index 2f46875..f998eac 100644
    --- a/docs/LAYOUTS.md
    +++ b/docs/LAYOUTS.md
    @@ -6,6 +6,8 @@
     
     

    Built-in Layouts

    +

    Run xfetch --list-layouts to print every accepted layout name.

    +

    1. Default Layout (Classic)

    diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index edbc07c..0110aa8 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -48,6 +48,38 @@ XFETCH_PLUGIN_REPO=https://github.com/user/plugins.git xfetch plugin install my- an extra development directory to that search path.

    +

    Manage installed plugins

    + +
    xfetch plugin list
    +xfetch plugin list --verbose
    +xfetch plugin info <name>
    +xfetch plugin search <query>
    +xfetch plugin available
    +xfetch plugin remove <name>
    + +

    + search reads the repository catalog (index.json), + filters by name, description, kind or runtime, and prints the matching + install command. available is an alias with an empty query, and + --json emits machine-readable results. Set + XFETCH_PLUGIN_INDEX to use a local file or a fork's catalog. +

    + +

    + After a successful install, xfetch prints the configuration snippet required + to enable the plugin, plus the declared capabilities and limits. + list --verbose adds the manifest kind, runtime, version, + capabilities and whether the plugin is currently enabled. info + prints the same details for one installed plugin. +

    + +

    + Direct wasm files and URLs accept --name <name> to + override the installed name and --manifest <file> to + attach a sidecar manifest. These options do not apply to directory or + repository installs, which already provide their own name and manifest. +

    +

    WebAssembly Plugins

    diff --git a/install-prebuilt.ps1 b/install-prebuilt.ps1 index 8812686..7cc44f0 100644 --- a/install-prebuilt.ps1 +++ b/install-prebuilt.ps1 @@ -5,7 +5,7 @@ # # Usage: # irm https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.ps1 | iex -# .\install-prebuilt.ps1 -Version 1.0.1 -BinDir C:\tools\bin +# .\install-prebuilt.ps1 -Version 1.0.2 -BinDir C:\tools\bin # .\install-prebuilt.ps1 -NoChecksum -NoPath param( diff --git a/install-prebuilt.sh b/install-prebuilt.sh index 7b283dd..40cb54a 100755 --- a/install-prebuilt.sh +++ b/install-prebuilt.sh @@ -3,7 +3,7 @@ # Prebuilt installer — downloads a precompiled binary from GitHub Releases. # Fast: no Rust toolchain, no compilation. Requires only curl (and tar/unzip). # Usage: curl -fsSL https://raw.githubusercontent.com/xfetch-cli/xfetch/main/install-prebuilt.sh | bash -# bash install-prebuilt.sh --version 1.0.1 +# bash install-prebuilt.sh --version 1.0.2 # bash install-prebuilt.sh --prefix /usr/local --yes set -euo pipefail @@ -174,7 +174,7 @@ Options: -y, --yes Automatic yes to all prompts -s, --skip-config Skip generating the default config -q, --quiet Quiet mode (minimal output) - --version Install a specific version (e.g. 1.0.1); default: latest release + --version Install a specific version (e.g. 1.0.2); default: latest release --require-signature Abort when cosign is unavailable or the signature bundle is missing --allow-unsigned Accept a signed release that is missing SHA256SUMS.bundle (SHA-256 only; not recommended) @@ -200,7 +200,7 @@ Examples: curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash # Install a specific version - curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.1 + curl -fsSL ${REPO_RAW}/install-prebuilt.sh | bash -s -- --version 1.0.2 # Non-interactive install bash install-prebuilt.sh --yes @@ -255,7 +255,7 @@ parse_args() { # ────────────────────────────────────────────── # A release version must be a dotted numeric version, optionally with a -# prerelease suffix (1.0.1, 1.0.1-rc1, 1.0.1-rc.2). Anything else is rejected +# prerelease suffix (1.0.2, 1.0.2-rc1, 1.0.2-rc.2). Anything else is rejected # instead of being silently coerced. validate_version() { printf '%s\n' "${1:-}" \ @@ -268,7 +268,7 @@ resolve_asset_url() { release_url="${REPO_API}/releases/latest" else validate_version "${VERSION}" \ - || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.1." + || die "Invalid --version '${VERSION}': expected a dotted numeric release such as 1.0.2." release_url="${REPO_API}/releases/tags/v${VERSION}" fi diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs new file mode 100644 index 0000000..513a2a6 --- /dev/null +++ b/src/addons/catalog.rs @@ -0,0 +1,248 @@ +//! Remote addon catalogs used by `search` and `available`. +//! +//! Each addon repository publishes an `index.json` at its root. The catalog is +//! intentionally small: it only carries the metadata needed to discover an +//! addon before installing it. Installation still uses the repository and +//! manifest, so the catalog cannot grant capabilities by itself. + +use crate::addons::model::AddonKind; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::time::Duration; + +/// Maximum catalog size accepted from a remote source. +const CATALOG_LIMIT: u64 = 2 * 1024 * 1024; + +/// Default raw catalog URL for each repository. +const PLUGIN_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/plugins/main/index.json"; +const EFFECT_CATALOG: &str = "https://raw.githubusercontent.com/xfetch-cli/effects/main/index.json"; +const EXTENSION_CATALOG: &str = + "https://raw.githubusercontent.com/xfetch-cli/extensions/main/index.json"; + +/// One addon advertised by a repository catalog. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogEntry { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub guest_runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} + +impl CatalogEntry { + fn install_name(&self) -> &str { + self.install.as_deref().unwrap_or(&self.name) + } + + fn matches(&self, query: &str) -> bool { + if query.is_empty() { + return true; + } + let query = query.to_ascii_lowercase(); + let haystack = [ + self.name.as_str(), + self.description.as_str(), + self.kind.as_deref().unwrap_or_default(), + self.runtime.as_deref().unwrap_or_default(), + ]; + haystack + .iter() + .any(|value| value.to_ascii_lowercase().contains(&query)) + } +} + +/// A repository catalog document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Catalog { + #[serde(default = "default_schema_version")] + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_version: Option, + #[serde(default)] + pub entries: Vec, +} + +fn default_schema_version() -> u32 { + 1 +} + +/// Fetches the catalog for `kind` and filters it by `query`. +pub fn search(kind: AddonKind, query: &str) -> Result, String> { + let catalog = load(kind)?; + Ok(catalog + .entries + .into_iter() + .filter(|entry| entry.matches(query)) + .collect()) +} + +/// Prints search results as a readable list or JSON. +pub fn print_search(kind: AddonKind, query: Option<&str>, json: bool) -> Result<(), String> { + let query = query.unwrap_or(""); + let entries = search(kind, query)?; + + if json { + let body = serde_json::to_string_pretty(&entries) + .map_err(|err| format!("Failed to serialize catalog results: {}", err))?; + println!("{}", body); + return Ok(()); + } + + if entries.is_empty() { + if query.is_empty() { + println!("No {} available in the catalog.", kind.plural_label()); + } else { + println!("No {} matched '{}'.", kind.plural_label(), query); + } + return Ok(()); + } + + println!("Available {}:", kind.plural_label()); + for entry in entries { + let version = entry.version.as_deref().unwrap_or("-"); + let entry_kind = entry.kind.as_deref().unwrap_or(kind.label()); + let runtime = entry.runtime.as_deref().unwrap_or("unknown"); + println!( + " {} ({}, {}, v{})", + entry.name, entry_kind, runtime, version + ); + if !entry.description.is_empty() { + println!(" {}", entry.description); + } + println!( + " install: {}", + install_command(kind, entry.install_name()) + ); + } + Ok(()) +} + +fn install_command(kind: AddonKind, name: &str) -> String { + match kind { + AddonKind::Plugin => format!("xfetch plugin install {}", name), + AddonKind::Effect => format!("xfetch effects install {}", name), + AddonKind::Extension => format!("xfetch extension install {}", name), + } +} + +fn load(kind: AddonKind) -> Result { + let source = catalog_source(kind); + let body = if source.starts_with("http://") || source.starts_with("https://") { + fetch_remote(&source)? + } else { + fs::read_to_string(&source) + .map_err(|err| format!("Failed to read catalog '{}': {}", source, err))? + }; + let catalog = parse(&body).map_err(|err| format!("Invalid catalog '{}': {}", source, err))?; + if catalog.schema_version != 1 { + return Err(format!( + "Unsupported catalog schema version {} in '{}'", + catalog.schema_version, source + )); + } + Ok(catalog) +} + +fn catalog_source(kind: AddonKind) -> String { + let (env_name, default) = match kind { + AddonKind::Plugin => ("XFETCH_PLUGIN_INDEX", PLUGIN_CATALOG), + AddonKind::Effect => ("XFETCH_EFFECT_INDEX", EFFECT_CATALOG), + AddonKind::Extension => ("XFETCH_EXTENSION_INDEX", EXTENSION_CATALOG), + }; + std::env::var(env_name).unwrap_or_else(|_| default.to_string()) +} + +fn fetch_remote(source: &str) -> Result { + let mut response = ureq::get(source) + .config() + .timeout_global(Some(Duration::from_secs(15))) + .build() + .call() + .map_err(|err| format!("Failed to fetch catalog '{}': {}", source, err))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(format!( + "Failed to fetch catalog '{}': HTTP {}", + source, status + )); + } + response + .body_mut() + .with_config() + .limit(CATALOG_LIMIT) + .read_to_string() + .map_err(|err| format!("Failed to read catalog '{}': {}", source, err)) +} + +fn parse(body: &str) -> Result { + serde_json::from_str(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" + { + "schema_version": 1, + "catalog_version": "1.0.2", + "entries": [ + { + "name": "wasm-crypto", + "description": "Crypto prices over HTTP", + "version": "0.2.0", + "kind": "info_provider", + "runtime": "wasm", + "path": "plugins/wasm-crypto", + "install": "wasm-crypto" + }, + { + "name": "weather", + "description": "Current weather", + "version": "0.2.0", + "kind": "info_provider", + "runtime": "native", + "path": "plugins/weather" + } + ] + } + "#; + + #[test] + fn parses_catalog_and_filters_entries() { + let catalog = parse(SAMPLE).expect("catalog"); + assert_eq!(catalog.entries.len(), 2); + assert!(catalog.entries[0].matches("crypto")); + assert!(catalog.entries[0].matches("WASM")); + assert!(!catalog.entries[0].matches("weather")); + assert_eq!(catalog.entries[1].install_name(), "weather"); + } + + #[test] + fn install_commands_match_the_command_family() { + assert_eq!( + install_command(AddonKind::Plugin, "weather"), + "xfetch plugin install weather" + ); + assert_eq!( + install_command(AddonKind::Effect, "matrix"), + "xfetch effects install matrix" + ); + assert_eq!( + install_command(AddonKind::Extension, "night"), + "xfetch extension install night" + ); + } +} diff --git a/src/addons/install.rs b/src/addons/install.rs new file mode 100644 index 0000000..1d19ea6 --- /dev/null +++ b/src/addons/install.rs @@ -0,0 +1,212 @@ +//! Helpers for direct wasm installs and post-install addon identity. + +use crate::addons::model::AddonKind; +use crate::install_support::validate_guest_name; +use crate::wasm::detect::is_wasm_file; +use crate::wasm::manifest::Manifest; +use std::fs; +use std::path::Path; + +/// Derives the installed name from a local path, URL or remote shorthand. +/// +/// The installer uses the same conventions, so this keeps the post-install +/// guidance and `info` commands aligned with the actual installed files. +pub fn infer_name( + kind: AddonKind, + source: &str, + name_override: Option<&str>, +) -> Result { + if let Some(name) = name_override { + validate_guest_name(name)?; + return Ok(name.to_string()); + } + + if is_url(source) { + let raw = crate::wasm::install::name_from_path(source) + .ok_or_else(|| format!("Cannot derive a name from URL '{}'", source))?; + let name = strip_prefix(kind, &raw); + validate_guest_name(&name)?; + return Ok(name); + } + + let path = Path::new(source); + if path.is_dir() { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("Cannot derive a name from '{}'", source))?; + validate_guest_name(name)?; + return Ok(name.to_string()); + } + + if path.is_file() { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("Cannot derive a name from '{}'", source))?; + let stem = file_name.strip_suffix(".wasm").unwrap_or(file_name); + let name = strip_prefix(kind, stem); + validate_guest_name(&name)?; + return Ok(name); + } + + // Remote repository paths and non-existent path-like sources install + // under the final path component. Apply the same suffix/prefix rules used + // for existing files so a dry-run path and the installed name agree. + let last = source.rsplit(['/', '\\']).next().unwrap_or(source); + let stem = last.strip_suffix(".wasm").unwrap_or(last); + let name = strip_prefix(kind, stem); + validate_guest_name(&name)?; + Ok(name) +} + +/// Installs a direct `.wasm` file or URL. This is the path used when the user +/// passes `--name` or `--manifest`, which the directory/repository installers +/// do not support. +pub fn install_direct( + kind: AddonKind, + source: &str, + sha256: Option<&str>, + name_override: Option<&str>, + manifest_path: Option<&str>, +) -> Result { + let name = infer_name(kind, source, name_override)?; + let directory = kind.directory(); + let manifest = manifest_path.map(parse_manifest).transpose()?; + + if is_url(source) { + crate::wasm::install::download_and_install( + source, + manifest, + sha256, + &directory, + kind.prefix(), + &name, + kind.label(), + )?; + return Ok(name); + } + + let path = Path::new(source); + if !path.is_file() || !is_wasm_file(path) { + return Err(format!( + "'{}' is not a WebAssembly file; --name/--manifest apply only to direct .wasm \ + files or URLs", + source + )); + } + + crate::wasm::install::install_file( + path, + sha256, + &directory, + kind.prefix(), + &name, + kind.label(), + )?; + + if let Some(manifest) = manifest { + write_sidecar(kind, &name, &manifest, manifest_path)?; + } + + Ok(name) +} + +/// Parses a user-supplied sidecar manifest before it is copied into the +/// installation directory. +pub fn parse_manifest(path: &str) -> Result { + let bytes = + fs::read(path).map_err(|err| format!("Failed to read manifest '{}': {}", path, err))?; + serde_json::from_slice(&bytes).map_err(|err| format!("Invalid manifest '{}': {}", path, err)) +} + +fn write_sidecar( + kind: AddonKind, + name: &str, + manifest: &Manifest, + source: Option<&str>, +) -> Result<(), String> { + let directory = kind.directory(); + fs::create_dir_all(&directory) + .map_err(|err| format!("Failed to create {} directory: {}", kind.label(), err))?; + let destination = directory.join(kind.manifest_name(name)); + let bytes = serde_json::to_vec_pretty(manifest) + .map_err(|err| format!("Failed to serialize manifest: {}", err))?; + crate::atomic_fs::write_atomic(&destination, &bytes).map_err(|err| { + format!( + "Failed to write manifest '{}': {}", + destination.display(), + err + ) + })?; + if let Some(source) = source { + println!("Installed manifest '{}'", source); + } + Ok(()) +} + +fn strip_prefix(kind: AddonKind, value: &str) -> String { + let stripped = value.strip_prefix(kind.prefix()).unwrap_or(value); + stripped + .strip_suffix(".wasm") + .unwrap_or(stripped) + .to_string() +} + +fn is_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn infers_plugin_name_from_wasm_file() { + assert_eq!( + infer_name(AddonKind::Plugin, "/tmp/xfetch-plugin-weather.wasm", None).expect("name"), + "weather" + ); + } + + #[test] + fn infers_effect_name_from_directory() { + assert_eq!( + infer_name(AddonKind::Effect, "/tmp/effects/wasm-matrix", None).expect("name"), + "wasm-matrix" + ); + } + + #[test] + fn infers_name_from_url() { + assert_eq!( + infer_name( + AddonKind::Extension, + "https://example.com/xfetch-extension-night-mode.wasm", + None + ) + .expect("name"), + "night-mode" + ); + } + + #[test] + fn accepts_remote_shorthand() { + assert_eq!( + infer_name(AddonKind::Plugin, "weather", None).expect("name"), + "weather" + ); + assert_eq!( + infer_name(AddonKind::Plugin, "plugins/weather", None).expect("name"), + "weather" + ); + } + + #[test] + fn override_wins() { + assert_eq!( + infer_name(AddonKind::Plugin, "/tmp/plugin.wasm", Some("custom")).expect("name"), + "custom" + ); + } +} diff --git a/src/addons/mod.rs b/src/addons/mod.rs new file mode 100644 index 0000000..f02a3ba --- /dev/null +++ b/src/addons/mod.rs @@ -0,0 +1,11 @@ +//! Shared addon metadata and user-facing guidance. +//! +//! Plugins, effects and extensions use different directories and binary +//! prefixes, but they share the same wasm manifest format and the same +//! install/list/info workflow. This module keeps the cross-cutting pieces in +//! one place so the three command families stay consistent. + +pub mod catalog; +pub mod install; +pub mod model; +pub mod report; diff --git a/src/addons/model.rs b/src/addons/model.rs new file mode 100644 index 0000000..0b3c8b3 --- /dev/null +++ b/src/addons/model.rs @@ -0,0 +1,408 @@ +//! Addon identity, installed metadata and configuration snippets. + +use crate::config::{Config, EffectConfigList, ModuleConfig}; +use crate::effects::{ + default_effect_dir, effect_binary_name, effect_manifest_name, effect_wasm_name, +}; +use crate::extensions::{ + default_extension_dir, extension_binary_name, extension_manifest_name, extension_wasm_name, +}; +use crate::plugins::{ + default_plugin_dir, plugin_binary_name, plugin_manifest_name, plugin_wasm_name, +}; +use crate::wasm::detect::is_wasm_file; +use crate::wasm::manifest::{self, Manifest, ManifestSource}; +use std::path::{Path, PathBuf}; + +/// The three installable addon families supported by the core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddonKind { + Plugin, + Effect, + Extension, +} + +impl AddonKind { + /// Singular label used in messages. + pub const fn label(self) -> &'static str { + match self { + Self::Plugin => "plugin", + Self::Effect => "effect", + Self::Extension => "extension", + } + } + + /// Plural label used in list headers. + pub const fn plural_label(self) -> &'static str { + match self { + Self::Plugin => "plugins", + Self::Effect => "effects", + Self::Extension => "extensions", + } + } + + /// Installed binary prefix (`xfetch-plugin-`, ...). + pub const fn prefix(self) -> &'static str { + match self { + Self::Plugin => "xfetch-plugin-", + Self::Effect => "xfetch-effect-", + Self::Extension => "xfetch-extension-", + } + } + + /// Default installation directory. + pub fn directory(self) -> PathBuf { + match self { + Self::Plugin => default_plugin_dir(), + Self::Effect => default_effect_dir(), + Self::Extension => default_extension_dir(), + } + } + + /// Native executable name for `name`. + pub fn binary_name(self, name: &str) -> String { + match self { + Self::Plugin => plugin_binary_name(name), + Self::Effect => effect_binary_name(name), + Self::Extension => extension_binary_name(name), + } + } + + /// Wasm artifact name for `name`. + pub fn wasm_name(self, name: &str) -> String { + match self { + Self::Plugin => plugin_wasm_name(name), + Self::Effect => effect_wasm_name(name), + Self::Extension => extension_wasm_name(name), + } + } + + /// Sidecar manifest name for `name`. + pub fn manifest_name(self, name: &str) -> String { + match self { + Self::Plugin => plugin_manifest_name(name), + Self::Effect => effect_manifest_name(name), + Self::Extension => extension_manifest_name(name), + } + } + + /// Finds the installed native or wasm artifact in the default directory. + pub fn installed_path(self, name: &str) -> Option { + let directory = self.directory(); + let binary = directory.join(self.binary_name(name)); + if binary.is_file() { + return Some(binary); + } + let wasm = directory.join(self.wasm_name(name)); + wasm.is_file().then_some(wasm) + } +} + +/// How an installed addon runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddonRuntime { + Native, + Wasm, +} + +impl AddonRuntime { + pub const fn as_str(self) -> &'static str { + match self { + Self::Native => "native", + Self::Wasm => "wasm", + } + } +} + +/// Installed addon metadata assembled from the artifact and its manifest. +#[derive(Debug, Clone)] +pub struct AddonEntry { + pub kind: AddonKind, + pub name: String, + pub path: PathBuf, + pub runtime: AddonRuntime, + pub manifest: Option, + pub manifest_source: Option, + pub enabled: bool, +} + +impl AddonEntry { + /// Loads an installed addon. Missing manifests are valid: the runtime then + /// applies the deny-by-default defaults. + pub fn load(kind: AddonKind, name: &str, path: &Path, config: &Config) -> Self { + let runtime = if is_wasm_file(path) { + AddonRuntime::Wasm + } else { + AddonRuntime::Native + }; + // Native addons have no embedded manifest; avoid reading large + // binaries just to look for one. + let loaded = if runtime == AddonRuntime::Wasm { + manifest::load(path).ok() + } else { + None + }; + let manifest = loaded.as_ref().and_then(|loaded| match loaded.source { + ManifestSource::Default => None, + _ => Some(loaded.manifest.clone()), + }); + let manifest_source = loaded.map(|loaded| loaded.source.as_str().to_string()); + Self { + kind, + name: name.to_string(), + path: path.to_path_buf(), + runtime, + manifest, + manifest_source, + enabled: is_enabled(kind, name, config), + } + } + + /// Manifest kind, falling back to the addon family. + pub fn manifest_kind(&self) -> &str { + self.manifest + .as_ref() + .and_then(|manifest| manifest.kind.as_deref()) + .unwrap_or_else(|| fallback_kind(self.kind)) + } + + pub fn version(&self) -> Option<&str> { + self.manifest + .as_ref() + .and_then(|manifest| manifest.version.as_deref()) + } + + pub fn description(&self) -> Option<&str> { + self.manifest + .as_ref() + .and_then(|manifest| manifest.description.as_deref()) + } + + /// Human-readable capability summary. Native addons report the process + /// boundary; wasm addons report the manifest allowlists. + pub fn capability_lines(&self) -> Vec { + let Some(manifest) = &self.manifest else { + return match self.runtime { + AddonRuntime::Native => vec![ + "Native process: runs unsandboxed with the current user's privileges." + .to_string(), + ], + AddonRuntime::Wasm => vec![ + "No manifest: HTTP, exec, filesystem, environment and args are denied by default." + .to_string(), + ], + }; + }; + + let capabilities = &manifest.capabilities; + let mut lines = Vec::new(); + lines.push(format!( + "HTTP allow: {}", + join_or_none(&capabilities.http.allow) + )); + lines.push(format!( + "Exec allow: {}", + join_or_none(&capabilities.exec.allow) + )); + lines.push(format!( + "Filesystem: {}", + filesystem_summary(&capabilities.fs) + )); + lines.push(format!("Environment: {}", join_or_none(&capabilities.env))); + lines.push(format!( + "Args: {}", + if capabilities.args { + "allowed" + } else { + "denied" + } + )); + lines + } + + /// Runtime limits in a single line. + pub fn limits_line(&self) -> Option { + let limits = self.manifest.as_ref()?.limits.clone(); + Some(format!( + "timeout {} ms, memory {} MiB, output {} KiB, host calls {} KiB", + limits.timeout_ms, limits.memory_mb, limits.output_kb, limits.host_call_kb + )) + } + + /// JSONC snippet that enables the addon in the user's config. + pub fn config_snippet(&self) -> Vec { + config_snippet(self.kind, &self.name, Some(self.manifest_kind())) + } +} + +/// Snippet for an addon whose manifest kind may be unknown (for example a +/// native plugin installed without a sidecar). +pub fn config_snippet(kind: AddonKind, name: &str, manifest_kind: Option<&str>) -> Vec { + match kind { + AddonKind::Plugin => plugin_snippet(name, manifest_kind), + AddonKind::Effect => vec![ + "\"effects\": [".to_string(), + format!(" {{ \"plugin\": \"{name}\" }}"), + "]".to_string(), + ], + AddonKind::Extension => vec![ + "\"config_providers\": [".to_string(), + format!(" {{ \"extension\": \"{name}\" }}"), + "]".to_string(), + ], + } +} + +fn plugin_snippet(name: &str, manifest_kind: Option<&str>) -> Vec { + let info = vec![ + "\"info_plugins\": [".to_string(), + format!(" {{ \"plugin\": \"{name}\" }}"), + "],".to_string(), + "\"modules\": [".to_string(), + " ...,".to_string(), + format!(" \"plugin:{name}\""), + "]".to_string(), + ]; + let logo = vec![ + "\"logo_animation\": {".to_string(), + format!(" \"plugin\": \"{name}\""), + "}".to_string(), + ]; + + match manifest_kind { + Some("logo_animation") => logo, + Some("info_provider") => info, + _ => { + let mut lines = vec!["Info provider:".to_string()]; + lines.extend(info.into_iter().map(|line| format!(" {line}"))); + lines.push(String::new()); + lines.push("Logo animation:".to_string()); + lines.extend(logo.into_iter().map(|line| format!(" {line}"))); + lines + } + } +} + +fn fallback_kind(kind: AddonKind) -> &'static str { + match kind { + AddonKind::Plugin => "plugin", + AddonKind::Effect => "effect", + AddonKind::Extension => "config_provider", + } +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values.join(", ") + } +} + +fn filesystem_summary(entries: &[crate::wasm::manifest::FsEntry]) -> String { + use crate::wasm::manifest::FsEntry; + + if entries.is_empty() { + return "none".to_string(); + } + + entries + .iter() + .map(|entry| match entry { + FsEntry::Path(path) => format!("{path} (read-only)"), + FsEntry::Mount { host, guest, mode } => { + format!("{host} -> {guest} ({mode:?})") + } + }) + .collect::>() + .join(", ") +} + +fn is_enabled(kind: AddonKind, name: &str, config: &Config) -> bool { + match kind { + AddonKind::Plugin => { + let info_configured = config + .info_plugins + .iter() + .any(|plugin| plugin.plugin == name); + let info_rendered = modules_include_plugin(&config.modules, name); + let logo_configured = config + .logo_animation + .as_ref() + .and_then(|animation| animation.plugin.as_deref()) + == Some(name); + (info_configured && info_rendered) || logo_configured + } + AddonKind::Effect => match &config.effects { + Some(EffectConfigList::Single(effect)) => effect.plugin.as_deref() == Some(name), + Some(EffectConfigList::Multiple(effects)) => effects + .iter() + .any(|effect| effect.plugin.as_deref() == Some(name)), + None => false, + }, + AddonKind::Extension => config + .config_providers + .iter() + .any(|provider| provider.extension == name), + } +} + +fn modules_include_plugin(modules: &[ModuleConfig], name: &str) -> bool { + let key = format!("plugin:{name}"); + modules.iter().any(|module| match module { + ModuleConfig::Simple(value) => value == &key, + ModuleConfig::Group { modules, .. } => modules_include_plugin(modules, name), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_modules(modules: Vec) -> Config { + Config { + modules, + ..Config::default() + } + } + + #[test] + fn info_snippet_includes_module_key() { + let snippet = config_snippet(AddonKind::Plugin, "weather", Some("info_provider")); + let text = snippet.join("\n"); + assert!(text.contains("\"info_plugins\"")); + assert!(text.contains("\"plugin:weather\"")); + } + + #[test] + fn logo_snippet_uses_logo_animation() { + let snippet = config_snippet(AddonKind::Plugin, "animate-logo", Some("logo_animation")); + let text = snippet.join("\n"); + assert!(text.contains("\"logo_animation\"")); + assert!(!text.contains("\"info_plugins\"")); + } + + #[test] + fn effect_and_extension_snippets_use_their_keys() { + let effect = config_snippet(AddonKind::Effect, "matrix", None).join("\n"); + let extension = config_snippet(AddonKind::Extension, "night", None).join("\n"); + assert!(effect.contains("\"effects\"")); + assert!(extension.contains("\"config_providers\"")); + } + + #[test] + fn info_plugin_enabled_only_when_module_is_present() { + let mut config = config_with_modules(vec![ModuleConfig::Simple("os".to_string())]); + config.info_plugins.push(crate::config::InfoPluginConfig { + plugin: "weather".to_string(), + args: None, + timeout_secs: None, + }); + assert!(!is_enabled(AddonKind::Plugin, "weather", &config)); + + config + .modules + .push(ModuleConfig::Simple("plugin:weather".to_string())); + assert!(is_enabled(AddonKind::Plugin, "weather", &config)); + } +} diff --git a/src/addons/report.rs b/src/addons/report.rs new file mode 100644 index 0000000..be7a373 --- /dev/null +++ b/src/addons/report.rs @@ -0,0 +1,116 @@ +//! Human-readable output for addon commands. + +use crate::addons::model::{AddonEntry, AddonKind}; +use crate::config::Config; + +/// Prints the installed addon list. The default layout is unchanged for +/// scripts; `verbose` adds kind, runtime, version, capabilities and state. +pub fn print_list(kind: AddonKind, entries: &[AddonEntry], verbose: bool) { + if entries.is_empty() { + println!("No {} installed.", kind.plural_label()); + println!("{} directory: {}", title(kind), kind.directory().display()); + return; + } + + if !verbose { + println!("Installed {}:", kind.plural_label()); + for entry in entries { + println!(" {} ({})", entry.name, entry.path.display()); + } + return; + } + + println!("Installed {}:", kind.plural_label()); + for entry in entries { + let version = entry.version().unwrap_or("-"); + let state = if entry.enabled { + "enabled" + } else { + "not enabled" + }; + println!( + " {} ({}, {}, v{}, {})", + entry.name, + entry.manifest_kind(), + entry.runtime.as_str(), + version, + state + ); + println!(" path: {}", entry.path.display()); + if let Some(description) = entry.description() { + println!(" description: {}", description); + } + for line in entry.capability_lines() { + println!(" {}", line); + } + if let Some(limits) = entry.limits_line() { + println!(" limits: {}", limits); + } + } +} + +/// Prints one installed addon with its manifest, capabilities and the config +/// snippet required to enable it. +pub fn print_info(entry: &AddonEntry) { + println!("Name: {}", entry.name); + println!("Type: {} ({})", entry.kind.label(), entry.manifest_kind()); + println!("Runtime: {}", entry.runtime.as_str()); + if let Some(version) = entry.version() { + println!("Version: {}", version); + } + if let Some(description) = entry.description() { + println!("Description: {}", description); + } + println!("Path: {}", entry.path.display()); + if let Some(source) = &entry.manifest_source { + println!("Manifest: {}", source); + } + println!("Enabled: {}", if entry.enabled { "yes" } else { "no" }); + + println!("Capabilities:"); + for line in entry.capability_lines() { + println!(" {}", line); + } + if let Some(limits) = entry.limits_line() { + println!("Limits: {}", limits); + } + + println!("Configuration:"); + for line in entry.config_snippet() { + println!(" {}", line); + } +} + +/// Prints the config snippet and declared capabilities after a successful +/// install. The installer has already printed the artifact path. +pub fn print_install_guidance(kind: AddonKind, name: &str) { + let path = kind + .installed_path(name) + .unwrap_or_else(|| kind.directory().join(kind.wasm_name(name))); + let entry = AddonEntry::load(kind, name, &path, &Config::default()); + + println!(); + println!("To enable '{}':", name); + for line in entry.config_snippet() { + println!("{}", line); + } + if let Some(limits) = entry.limits_line() { + println!(); + println!("Limits: {}", limits); + } + let capabilities = entry.capability_lines(); + if !capabilities.is_empty() { + println!("Capabilities:"); + for line in capabilities { + println!(" {}", line); + } + } +} + +fn title(kind: AddonKind) -> &'static str { + match kind { + AddonKind::Plugin => "Plugin", + AddonKind::Effect => "Effect", + AddonKind::Extension => "Extension", + } +} diff --git a/src/cli.rs b/src/cli.rs index 611f78e..8a93443 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,7 @@ use clap::{Parser, Subcommand}; version, about, long_about = None, - after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme" + after_help = "Examples:\n xfetch\n xfetch --config ~/.config/xfetch/config.jsonc\n xfetch --gen-config\n xfetch --gen-config --layout tree\n xfetch --list-layouts\n xfetch --clean-cache\n xfetch --daemon\n xfetch --daemon-stop\n xfetch --no-daemon-live\n xfetch --daemon-live-stop\n xfetch --daemon-live-reload\n xfetch update --check\n xfetch update\n xfetch plugin install animate-logo\n xfetch plugin list --verbose\n xfetch plugin info animate-logo\n xfetch plugin search logo\n xfetch plugin remove animate-logo\n xfetch effects install decrypt\n xfetch effects list --verbose\n xfetch effects info decrypt\n xfetch effects available\n xfetch effects remove decrypt\n xfetch extension install config-roulette\n xfetch extension list --verbose\n xfetch extension info config-roulette\n xfetch extension search night\n xfetch extension remove config-roulette\n xfetch wasm inspect ./plugin.wasm\n xfetch wasm run ./plugin.wasm --request '{\"version\":1,\"kind\":\"info_provider\"}'\n xfetch wasm wit\n xfetch theme list\n xfetch theme set dracula\n xfetch theme remove dracula\n xfetch theme export my-theme" )] pub struct Cli { #[command(subcommand)] @@ -25,10 +25,15 @@ pub struct Cli { pub logo: Option, /// Layout for the generated config (e.g. `section`, `tree`, `compact`). - /// Only used with `--gen-config`; defaults to `pacman`. + /// Only used with `--gen-config`; defaults to `section`. Run + /// `xfetch --list-layouts` to see every accepted name. #[arg(long, global = true)] pub layout: Option, + /// Print the layout names accepted by `--layout` and exit. + #[arg(long, global = true)] + pub list_layouts: bool, + #[arg(long, global = true)] pub clean_cache: bool, @@ -58,10 +63,12 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Commands { + #[command(alias = "plugins")] Plugin { #[command(subcommand)] action: PluginCommands, }, + #[command(alias = "extensions")] Extension { #[command(subcommand)] action: ExtensionCommands, @@ -70,6 +77,7 @@ pub enum Commands { #[command(subcommand)] action: ThemeCommands, }, + #[command(alias = "effect")] Effects { #[command(subcommand)] action: EffectCommands, @@ -107,8 +115,29 @@ pub enum PluginCommands { /// Expected SHA-256 (hex) of a downloaded wasm artifact. #[arg(long, value_name = "HEX")] sha256: Option, + /// Override the installed name for a direct wasm file or URL. + #[arg(long)] + name: Option, + /// Sidecar manifest to attach to a direct wasm file or URL. + #[arg(long, value_name = "FILE")] + manifest: Option, + }, + List { + /// Show kind, runtime, version, capabilities and enabled state. + #[arg(long, short)] + verbose: bool, + }, + Info { + name: String, + }, + /// Search the remote plugin catalog. Omit the query to list everything. + #[command(alias = "available")] + Search { + query: Option, + /// Emit the matching entries as JSON. + #[arg(long)] + json: bool, }, - List, Remove { name: String, }, @@ -123,8 +152,29 @@ pub enum ExtensionCommands { /// Expected SHA-256 (hex) of a downloaded wasm artifact. #[arg(long, value_name = "HEX")] sha256: Option, + /// Override the installed name for a direct wasm file or URL. + #[arg(long)] + name: Option, + /// Sidecar manifest to attach to a direct wasm file or URL. + #[arg(long, value_name = "FILE")] + manifest: Option, + }, + List { + /// Show kind, runtime, version, capabilities and enabled state. + #[arg(long, short)] + verbose: bool, + }, + Info { + name: String, + }, + /// Search the remote extension catalog. Omit the query to list everything. + #[command(alias = "available")] + Search { + query: Option, + /// Emit the matching entries as JSON. + #[arg(long)] + json: bool, }, - List, Remove { name: String, }, @@ -147,8 +197,29 @@ pub enum EffectCommands { /// Expected SHA-256 (hex) of a downloaded wasm artifact. #[arg(long, value_name = "HEX")] sha256: Option, + /// Override the installed name for a direct wasm file or URL. + #[arg(long)] + name: Option, + /// Sidecar manifest to attach to a direct wasm file or URL. + #[arg(long, value_name = "FILE")] + manifest: Option, + }, + List { + /// Show kind, runtime, version, capabilities and enabled state. + #[arg(long, short)] + verbose: bool, + }, + Info { + name: String, + }, + /// Search the remote effect catalog. Omit the query to list everything. + #[command(alias = "available")] + Search { + query: Option, + /// Emit the matching entries as JSON. + #[arg(long)] + json: bool, }, - List, Remove { name: String, }, diff --git a/src/info/hardware.rs b/src/info/hardware.rs index 13c0282..aac9f9f 100644 --- a/src/info/hardware.rs +++ b/src/info/hardware.rs @@ -69,13 +69,15 @@ mod tests { #[test] fn test_get_memory_info() { - let mem = get_memory_info(&System::new_all()); + let sys = System::new_all().expect("system information"); + let mem = get_memory_info(&sys); assert!(mem.contains("GiB"), "memory should show GiB"); } #[test] fn test_get_swap_info() { - let swap = get_swap_info(&System::new_all()); + let sys = System::new_all().expect("system information"); + let swap = get_swap_info(&sys); assert!(swap.contains("GiB") || swap == "0 B / 0 B (0%)"); } } diff --git a/src/info/mod.rs b/src/info/mod.rs index 4ddbbd6..5138d08 100644 --- a/src/info/mod.rs +++ b/src/info/mod.rs @@ -121,9 +121,9 @@ impl Info { let networks_h = needs_network(&needed).then(|| s.spawn(Networks::new_with_refreshed_list)); ( - sys_h.and_then(|h| h.join().ok()), - disks_h.and_then(|h| h.join().ok()), - networks_h.and_then(|h| h.join().ok()), + sys_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()), + disks_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()), + networks_h.and_then(|h| h.join().ok()).and_then(|r| r.ok()), ) }); diff --git a/src/info/platform/linux/mod.rs b/src/info/platform/linux/mod.rs index 4048b7c..d46e9e5 100644 --- a/src/info/platform/linux/mod.rs +++ b/src/info/platform/linux/mod.rs @@ -11,6 +11,7 @@ pub mod live; pub mod network; pub mod os_release; pub mod packages; +mod xpm; pub use battery::get_battery_info; pub use datetime::get_datetime_info; diff --git a/src/info/platform/linux/network.rs b/src/info/platform/linux/network.rs index 6b4ff21..3d16083 100644 --- a/src/info/platform/linux/network.rs +++ b/src/info/platform/linux/network.rs @@ -41,7 +41,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String { if let std::net::IpAddr::V4(ipv4) = ip.addr && !ipv4.is_loopback() { - if is_virtual(name) { + if is_virtual(name.to_string_lossy().as_ref()) { virtual_fallback.get_or_insert_with(|| ipv4.to_string()); } else { return ipv4.to_string(); diff --git a/src/info/platform/linux/packages.rs b/src/info/platform/linux/packages.rs index 735cb37..8bf881b 100644 --- a/src/info/platform/linux/packages.rs +++ b/src/info/platform/linux/packages.rs @@ -1,5 +1,6 @@ use std::path::Path; +use crate::info::platform::linux::xpm; use crate::info::platform::shared::packages::{ PACKAGE_CHECK_TIMEOUT, PackageCheck, SNAP_CHECK_TIMEOUT, format_package_count, run_package_checks, @@ -216,7 +217,7 @@ pub fn get_packages_breakdown() -> Vec<(String, String)> { .cloned() .collect(); let cmd_counts = run_package_checks(&pending); - CHECKS + let mut breakdown: Vec<(String, String)> = CHECKS .iter() .filter_map(|check| { if let Some((_, n)) = db_counts.iter().find(|(c, _)| *c == check.label) { @@ -230,7 +231,26 @@ pub fn get_packages_breakdown() -> Vec<(String, String)> { cmd_counts.iter().find(|(c, _)| c == check.label).cloned() } }) - .collect() + .collect(); + + for (label, count) in xpm::package_counts() { + breakdown.push((label.to_string(), format_package_count(count, label))); + } + + let covered = breakdown + .iter() + .any(|(label, _)| label == xpm::XPM_LABEL || label == PACMAN_CMD); + if !covered && let Some(count) = xpm::pacman_db_package_count() { + breakdown.insert( + 0, + ( + PACMAN_CMD.to_string(), + format_package_count(count, PACMAN_CMD), + ), + ); + } + + breakdown } #[cfg(test)] diff --git a/src/info/platform/linux/xpm.rs b/src/info/platform/linux/xpm.rs new file mode 100644 index 0000000..fd392c0 --- /dev/null +++ b/src/info/platform/linux/xpm.rs @@ -0,0 +1,361 @@ +//! Package counts for the X package manager (`xpm`) and its AUR helper +//! (`xpm-aur`). +//! +//! `xpm` keeps its own local database at `/var/lib/xpm/local` and mirrors +//! transactions into pacman's local database at `/var/lib/pacman/local`. The +//! `xpm` count is the union of both databases, which is the locally installed +//! set xpm can see; pacman-only packages are included because xpm shares that +//! database through the mirror. +//! +//! Each xpm `/version` file marks a valid installed package and +//! `/origin` records the repository it came from. The native X +//! repository is `x`, so packages with that origin are reported as `xp` +//! (native `.xp` packages). Packages pulled from Arch repositories through +//! xpm's pacman compatibility layer still count towards the `xpm` total but +//! not towards `xp`. +//! +//! `xpm-aur` tracks the AUR packages it installed in +//! `$XDG_STATE_HOME/xpm-aur/state.json`; the count is the number of installed +//! split outputs across all tracked package bases. The sidecar is intersected +//! with the shared installed set so only packages that are actually installed +//! are counted. + +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +pub const XPM_LABEL: &str = "xpm"; +pub const XP_LABEL: &str = "xp"; +pub const XPM_AUR_LABEL: &str = "xpm-aur"; + +const XPM_DB_DIR: &str = "/var/lib/xpm/local"; +const PACMAN_DB_DIR: &str = "/var/lib/pacman/local"; +const PACMAN_DESC_FILE: &str = "desc"; +const PACMAN_NAME_TAG: &str = "%NAME%"; +const XPM_BINARY: &str = "xpm"; +const XPM_AUR_FALLBACK_DIR: &str = "/var/lib/xpm-aur"; + +/// Repository names that publish native X packages. `x` is the native endpoint +/// used by `xpm`'s example configuration; extend this list if more native +/// repositories are added. +const X_NATIVE_REPOS: &[&str] = &["x"]; + +const VERSION_FILE: &str = "version"; +const ORIGIN_FILE: &str = "origin"; + +#[derive(Debug, Default, Deserialize)] +struct AurState { + #[serde(default)] + packages: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct AurRecord { + #[serde(default)] + packages: Vec, +} + +/// Returns the `xpm`, `xp` and `xpm-aur` counts that apply to this machine. +/// +/// The list is empty when xpm is not present. `xp` is always present alongside +/// `xpm` (it may be zero) so the native package count is explicit. `xpm-aur` +/// is only present when its state file can be read. Every count is derived +/// from locally installed packages; remote catalogs are never queried. +pub fn package_counts() -> Vec<(&'static str, usize)> { + let xpm_db = Path::new(XPM_DB_DIR); + let xpm_names = installed_package_names(xpm_db).unwrap_or_default(); + let pacman_names = pacman_package_names(Path::new(PACMAN_DB_DIR)).unwrap_or_default(); + + if xpm_names.is_empty() && !xpm_db.is_dir() && !command_exists(XPM_BINARY) { + return Vec::new(); + } + + let mut installed = xpm_names.clone(); + installed.extend(pacman_names); + + let mut counts = vec![(XPM_LABEL, installed.len())]; + + let native = count_xp_packages(xpm_db, &xpm_names, X_NATIVE_REPOS); + counts.push((XP_LABEL, native)); + + let state = xpm_aur_state_path(); + if let Some(count) = state + .as_deref() + .and_then(|path| count_xpm_aur_packages(path, &installed)) + { + counts.push((XPM_AUR_LABEL, count)); + } + + counts +} + +/// Returns the names of packages installed in the xpm local database. +/// +/// A directory counts only when it contains a `version` file, matching what +/// `xpm query` reports, and dot-prefixed entries are ignored. +fn installed_package_names(db: &Path) -> Option> { + let mut names = HashSet::new(); + + for entry in std::fs::read_dir(db).ok()?.filter_map(Result::ok) { + if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { + continue; + } + if is_dot_entry(&entry.file_name()) { + continue; + } + if !entry.path().join(VERSION_FILE).is_file() { + continue; + } + names.insert(entry.file_name().to_string_lossy().into_owned()); + } + + Some(names) +} + +/// Returns the names of packages in pacman's local database. +/// +/// pacman names its entries `--` and stores the +/// exact name in `desc`; xpm-mirrored entries also land here in that format. +/// xpm-style entries (`/version`) are accepted too, in case xpm is +/// configured to use this directory directly. +fn pacman_package_names(db: &Path) -> Option> { + let mut names = HashSet::new(); + + for entry in std::fs::read_dir(db).ok()?.filter_map(Result::ok) { + if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { + continue; + } + if is_dot_entry(&entry.file_name()) { + continue; + } + + if entry.path().join(VERSION_FILE).is_file() { + names.insert(entry.file_name().to_string_lossy().into_owned()); + continue; + } + + if let Some(name) = pacman_desc_name(&entry.path()) { + names.insert(name); + } + } + + Some(names) +} + +/// Counts the packages in pacman's local database. +/// +/// Used as a fallback when neither xpm nor the `pacman` command is available +/// but the shared database still exists. +pub(crate) fn pacman_db_package_count() -> Option { + pacman_package_names(Path::new(PACMAN_DB_DIR)).map(|names| names.len()) +} + +fn pacman_desc_name(pkg_dir: &Path) -> Option { + let desc = std::fs::read_to_string(pkg_dir.join(PACMAN_DESC_FILE)).ok()?; + let mut lines = desc.lines(); + + while let Some(line) = lines.next() { + if line.trim() == PACMAN_NAME_TAG { + return lines + .next() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string); + } + } + + None +} + +fn command_exists(name: &str) -> bool { + std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())) +} + +/// Counts installed packages whose recorded origin is a native X repository. +fn count_xp_packages(db: &Path, installed: &HashSet, native_repos: &[&str]) -> usize { + installed + .iter() + .filter(|name| { + std::fs::read_to_string(db.join(name).join(ORIGIN_FILE)) + .ok() + .is_some_and(|origin| native_repos.contains(&origin.trim())) + }) + .count() +} + +/// Counts the installed package outputs recorded by `xpm-aur`. +/// +/// Records and split outputs that are no longer present in the xpm local +/// database are ignored, so stale sidecar entries do not inflate the count. +fn count_xpm_aur_packages(state_file: &Path, installed: &HashSet) -> Option { + let text = std::fs::read_to_string(state_file).ok()?; + let state: AurState = serde_json::from_str(&text).ok()?; + + let mut count = 0; + for (base, record) in &state.packages { + if record.packages.is_empty() { + if installed.contains(base) { + count += 1; + } + } else { + count += record + .packages + .iter() + .filter(|name| installed.contains(*name)) + .count(); + } + } + + Some(count) +} + +fn xpm_aur_state_path() -> Option { + if let Some(path) = std::env::var_os("XPM_AUR_STATE") { + return Some(PathBuf::from(path)); + } + let base = dirs::state_dir() + .or_else(|| dirs::home_dir().map(|home| home.join(".local/state"))) + .unwrap_or_else(|| PathBuf::from(XPM_AUR_FALLBACK_DIR)); + Some(base.join("xpm-aur/state.json")) +} + +fn is_dot_entry(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|name| name.starts_with('.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xfetch_test_xpm_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn package(db: &Path, name: &str, origin: Option<&str>) { + let dir = db.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(VERSION_FILE), "1.0-1").unwrap(); + if let Some(origin) = origin { + std::fs::write(dir.join(ORIGIN_FILE), origin).unwrap(); + } + } + + #[test] + fn test_installed_package_names_skips_incomplete_entries() { + let db = temp_dir("xpm_total"); + package(&db, "foo", Some("x")); + package(&db, "bar", Some("core")); + std::fs::create_dir_all(db.join("incomplete")).unwrap(); + std::fs::create_dir_all(db.join(".partial")).unwrap(); + std::fs::write(db.join(".partial").join(VERSION_FILE), "1.0").unwrap(); + std::fs::write(db.join("db.lck"), "").unwrap(); + + let installed = installed_package_names(&db).unwrap(); + assert_eq!(installed.len(), 2); + assert!(installed.contains("foo")); + assert!(installed.contains("bar")); + let _ = std::fs::remove_dir_all(&db); + } + + #[test] + fn test_pacman_package_names_reads_desc_and_xpm_style_entries() { + let db = temp_dir("pacman_total"); + let desc_dir = db.join("foo-1.0-1"); + std::fs::create_dir_all(&desc_dir).unwrap(); + std::fs::write( + desc_dir.join(PACMAN_DESC_FILE), + "%NAME%\nfoo\n\n%VERSION%\n1.0-1\n", + ) + .unwrap(); + package(&db, "bar", None); + std::fs::create_dir_all(db.join("incomplete-1.0-1")).unwrap(); + std::fs::write(db.join("db.lck"), "").unwrap(); + + let names = pacman_package_names(&db).unwrap(); + assert_eq!(names.len(), 2); + assert!(names.contains("foo")); + assert!(names.contains("bar")); + let _ = std::fs::remove_dir_all(&db); + } + + #[test] + fn test_count_xp_packages_filters_by_native_origin() { + let db = temp_dir("xp_total"); + package(&db, "native-a", Some("x")); + package(&db, "native-b", Some("x\n")); + package(&db, "arch", Some("core")); + package(&db, "local", Some("local")); + package(&db, "no-origin", None); + + let installed = installed_package_names(&db).unwrap(); + assert_eq!(count_xp_packages(&db, &installed, &["x"]), 2); + assert_eq!(count_xp_packages(&db, &installed, &["x", "x-extra"]), 2); + let _ = std::fs::remove_dir_all(&db); + } + + #[test] + fn test_count_xpm_aur_packages_sums_outputs() { + let dir = temp_dir("aur_state"); + let state = dir.join("state.json"); + std::fs::write( + &state, + r#"{ + "schema": 1, + "packages": { + "yay": { + "base": "yay", + "version": "1.0", + "commit": "abc", + "installed_at": 1, + "explicit": true, + "packages": ["yay", "yay-debug"] + }, + "old": { + "base": "old", + "version": "2.0", + "commit": "def", + "installed_at": 2, + "explicit": false, + "packages": [] + }, + "stale": { + "base": "stale", + "version": "3.0", + "commit": "ghi", + "installed_at": 3, + "explicit": false, + "packages": ["stale-pkg"] + } + } + }"#, + ) + .unwrap(); + + let installed: HashSet = ["yay", "yay-debug", "old"] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(count_xpm_aur_packages(&state, &installed), Some(3)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_count_xpm_aur_packages_rejects_invalid_state() { + let dir = temp_dir("aur_invalid"); + let state = dir.join("state.json"); + std::fs::write(&state, "not json").unwrap(); + let installed = HashSet::new(); + assert_eq!(count_xpm_aur_packages(&state, &installed), None); + assert_eq!( + count_xpm_aur_packages(&dir.join("missing.json"), &installed), + None + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/info/platform/macos/network.rs b/src/info/platform/macos/network.rs index 0419448..c1df7ca 100644 --- a/src/info/platform/macos/network.rs +++ b/src/info/platform/macos/network.rs @@ -42,7 +42,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String { if let std::net::IpAddr::V4(ipv4) = ip.addr && !ipv4.is_loopback() { - if is_virtual(name) { + if is_virtual(name.to_string_lossy().as_ref()) { virtual_fallback.get_or_insert_with(|| ipv4.to_string()); } else { return ipv4.to_string(); diff --git a/src/info/platform/macos/version.rs b/src/info/platform/macos/version.rs index e9320d0..7e928e3 100644 --- a/src/info/platform/macos/version.rs +++ b/src/info/platform/macos/version.rs @@ -42,7 +42,7 @@ pub fn macos_version_id(version: &str) -> Option<&'static str> { /// mapped. pub fn detect_os_ids() -> (String, Vec) { let mut ids = Vec::new(); - if let Some(v) = sysinfo::System::os_version() + if let Ok(v) = sysinfo::System::os_version() && let Some(specific) = macos_version_id(&v) { ids.push(specific.to_string()); diff --git a/src/info/platform/mod.rs b/src/info/platform/mod.rs index a1bf79a..bf2d12f 100644 --- a/src/info/platform/mod.rs +++ b/src/info/platform/mod.rs @@ -62,7 +62,7 @@ pub fn detect_os_ids() -> (String, Vec) { #[cfg(target_os = "windows")] { let mut ids = Vec::new(); - if let Some(v) = sysinfo::System::os_version() + if let Ok(v) = sysinfo::System::os_version() && let Some(specific) = windows::version::windows_version_id(&v) { ids.push(specific.to_string()); diff --git a/src/info/platform/windows/network.rs b/src/info/platform/windows/network.rs index e0152fa..847c0d8 100644 --- a/src/info/platform/windows/network.rs +++ b/src/info/platform/windows/network.rs @@ -41,7 +41,7 @@ pub fn get_local_ip_info(networks: &Networks) -> String { if let std::net::IpAddr::V4(ipv4) = ip.addr && !ipv4.is_loopback() { - if is_virtual(name) { + if is_virtual(name.to_string_lossy().as_ref()) { virtual_fallback.get_or_insert_with(|| ipv4.to_string()); } else { return ipv4.to_string(); diff --git a/src/info/platform/windows/shell.rs b/src/info/platform/windows/shell.rs index 95c195b..0ab950b 100644 --- a/src/info/platform/windows/shell.rs +++ b/src/info/platform/windows/shell.rs @@ -43,7 +43,7 @@ fn shell_label(name: &str) -> String { /// process chain. `None` when no known shell is found (e.g. launched by /// Explorer); the caller falls back to env detection. pub fn detect_shell_name() -> Option { - let mut sys = System::new(); + let mut sys = System::new().ok()?; sys.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing()); let current = sysinfo::get_current_pid().ok()?; let mut proc = sys.process(current)?; diff --git a/src/info/system.rs b/src/info/system.rs index ef890ed..debb97e 100644 --- a/src/info/system.rs +++ b/src/info/system.rs @@ -1,4 +1,3 @@ -use std::io::Read; use std::net::IpAddr; use std::sync::LazyLock; use std::thread; @@ -9,7 +8,7 @@ use ureq::Agent; const PUBLIC_IP_HOSTS: [&str; 3] = ["ifconfig.me", "api.ipify.org", "icanhazip.com"]; pub fn get_os_info() -> String { - let name = System::name().unwrap_or_else(super::unknown); + let name = System::name().unwrap_or_else(|_| super::unknown()); let version = System::os_version().unwrap_or_default(); let arch = std::env::consts::ARCH; if version.is_empty() { @@ -20,15 +19,15 @@ pub fn get_os_info() -> String { } pub fn get_kernel_info() -> String { - System::kernel_version().unwrap_or_else(super::unknown) + System::kernel_version().unwrap_or_else(|_| super::unknown()) } pub fn get_host_name() -> String { - System::host_name().unwrap_or_else(super::unknown) + System::host_name().unwrap_or_else(|_| super::unknown()) } pub fn get_uptime_info() -> String { - let uptime = System::uptime(); + let uptime = System::uptime().unwrap_or(0); let hours = uptime / 3600; let mins = (uptime % 3600) / 60; let hour_label = if hours == 1 { "hour" } else { "hours" }; @@ -61,11 +60,12 @@ pub fn get_ipv6_info(networks: &Networks) -> String { /// No redirects: the IP endpoints serve the address directly, and a /// redirect to plaintext HTTP would silently downgrade the request. static AGENT: LazyLock = LazyLock::new(|| { - ureq::AgentBuilder::new() - .timeout_connect(Duration::from_secs(3)) - .timeout_read(Duration::from_secs(5)) - .redirects(0) - .build() + let config = Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(3))) + .timeout_per_read(Some(Duration::from_secs(5))) + .max_redirects(0) + .build(); + config.into() }); /// Validates the response body is a real IP address. Stricter than the old @@ -81,12 +81,12 @@ fn parse_public_ip(body: &str) -> Option { fn fetch_public_ip_from(host: &str) -> Option { // Cap the body at 64 bytes: a public IP is at most 45 chars. - let response = AGENT.get(&format!("https://{host}/")).call().ok()?; - let mut body = String::new(); - response - .into_reader() - .take(64) - .read_to_string(&mut body) + let mut response = AGENT.get(&format!("https://{host}/")).call().ok()?; + let body = response + .body_mut() + .with_config() + .limit(64) + .read_to_string() .ok()?; parse_public_ip(&body).map(|ip| ip.to_string()) } @@ -134,9 +134,9 @@ pub fn get_network_interfaces_info(networks: &Networks) -> String { .collect(); if !ips.is_empty() { let label = if mac.is_empty() { - format!("{} {}", name, ips.join(", ")) + format!("{} {}", name.to_string_lossy(), ips.join(", ")) } else { - format!("{} [{}] {}", name, mac, ips.join(", ")) + format!("{} [{}] {}", name.to_string_lossy(), mac, ips.join(", ")) }; parts.push(label); } diff --git a/src/main.rs b/src/main.rs index 6eddaa7..dbff34b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod addons; mod atomic_fs; mod cache; mod cli; @@ -17,6 +18,8 @@ mod ui; mod update; mod wasm; +use crate::addons::install::infer_name; +use crate::addons::model::{AddonEntry, AddonKind}; use crate::config::{generate_config, load_config}; use crate::effects::{install_effect, list_effects, remove_effect}; use crate::extensions::{install_extension, list_extensions, remove_extension}; @@ -36,6 +39,13 @@ fn main() { } fn run(cli: Cli) -> Result<(), error::XFetchError> { + if cli.list_layouts { + for name in crate::ui::LAYOUT_NAMES { + println!("{name}"); + } + return Ok(()); + } + if cli.clean_cache { return cache::clean() .map_err(|err| error::XFetchError::CleanCache(err.to_string())) @@ -75,59 +85,132 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> { match cli.command { Some(Commands::Plugin { action }) => match action { - PluginCommands::Install { path, repo, sha256 } => { - install_plugin(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal) + PluginCommands::Install { + path, + repo, + sha256, + name, + manifest, + } => { + let direct = name.is_some() || manifest.is_some(); + let installed = if direct { + if repo.is_some() { + return Err(error::XFetchError::Fatal( + "--name/--manifest apply to direct wasm files or URLs; omit --repo" + .to_string(), + )); + } + addons::install::install_direct( + AddonKind::Plugin, + &path, + sha256.as_deref(), + name.as_deref(), + manifest.as_deref(), + ) + .map_err(error::XFetchError::Fatal)? + } else { + install_plugin(&path, repo.as_deref(), sha256.as_deref()) + .map_err(error::XFetchError::Fatal)?; + infer_name(AddonKind::Plugin, &path, None).map_err(error::XFetchError::Fatal)? + }; + addons::report::print_install_guidance(AddonKind::Plugin, &installed); + Ok(()) + } + PluginCommands::List { verbose } => { + let installed = list_plugins().map_err(error::XFetchError::Fatal)?; + let config = load_config(cli.config.clone()); + let entries = installed + .into_iter() + .map(|(name, path)| AddonEntry::load(AddonKind::Plugin, &name, &path, &config)) + .collect::>(); + addons::report::print_list(AddonKind::Plugin, &entries, verbose); + Ok(()) } - PluginCommands::List => { - list_plugins() + PluginCommands::Info { name } => { + let path = AddonKind::Plugin + .installed_path(&name) + .or_else(|| plugins::find_plugin_binary(&name)) + .ok_or_else(|| { + error::XFetchError::Fatal(format!("Plugin '{}' is not installed.", name)) + })?; + let config = load_config(cli.config.clone()); + let entry = AddonEntry::load(AddonKind::Plugin, &name, &path, &config); + addons::report::print_info(&entry); + Ok(()) + } + PluginCommands::Search { query, json } => { + addons::catalog::print_search(AddonKind::Plugin, query.as_deref(), json) .map_err(error::XFetchError::Fatal) - .map(|plugins| { - if plugins.is_empty() { - println!("No plugins installed."); - println!( - "Plugin directory: {}", - plugins::default_plugin_dir().display() - ); - } else { - println!("Installed plugins:"); - for (name, path) in &plugins { - println!(" {} ({})", name, path.display()); - } - } - }) } PluginCommands::Remove { name } => { remove_plugin(&name).map_err(error::XFetchError::Fatal) } }, - Some(Commands::Extension { action }) => { - match action { - ExtensionCommands::Install { path, repo, sha256 } => { + Some(Commands::Extension { action }) => match action { + ExtensionCommands::Install { + path, + repo, + sha256, + name, + manifest, + } => { + let direct = name.is_some() || manifest.is_some(); + let installed = if direct { + if repo.is_some() { + return Err(error::XFetchError::Fatal( + "--name/--manifest apply to direct wasm files or URLs; omit --repo" + .to_string(), + )); + } + addons::install::install_direct( + AddonKind::Extension, + &path, + sha256.as_deref(), + name.as_deref(), + manifest.as_deref(), + ) + .map_err(error::XFetchError::Fatal)? + } else { install_extension(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal) - } - ExtensionCommands::List => list_extensions() + .map_err(error::XFetchError::Fatal)?; + infer_name(AddonKind::Extension, &path, None) + .map_err(error::XFetchError::Fatal)? + }; + addons::report::print_install_guidance(AddonKind::Extension, &installed); + Ok(()) + } + ExtensionCommands::List { verbose } => { + let installed = list_extensions().map_err(error::XFetchError::Fatal)?; + let config = load_config(cli.config.clone()); + let entries = installed + .into_iter() + .map(|(name, path)| { + AddonEntry::load(AddonKind::Extension, &name, &path, &config) + }) + .collect::>(); + addons::report::print_list(AddonKind::Extension, &entries, verbose); + Ok(()) + } + ExtensionCommands::Info { name } => { + let path = AddonKind::Extension + .installed_path(&name) + .or_else(|| extensions::find_extension_binary(&name)) + .ok_or_else(|| { + error::XFetchError::Fatal(format!("Extension '{}' is not installed.", name)) + })?; + let config = load_config(cli.config.clone()); + let entry = AddonEntry::load(AddonKind::Extension, &name, &path, &config); + addons::report::print_info(&entry); + Ok(()) + } + ExtensionCommands::Search { query, json } => { + addons::catalog::print_search(AddonKind::Extension, query.as_deref(), json) .map_err(error::XFetchError::Fatal) - .map(|extensions| { - if extensions.is_empty() { - println!("No extensions installed."); - println!( - "Extension directory: {}", - extensions::default_extension_dir().display() - ); - } else { - println!("Installed extensions:"); - for (name, path) in &extensions { - println!(" {} ({})", name, path.display()); - } - } - }), - ExtensionCommands::Remove { name } => { - remove_extension(&name).map_err(error::XFetchError::Fatal) - } } - } + ExtensionCommands::Remove { name } => { + remove_extension(&name).map_err(error::XFetchError::Fatal) + } + }, Some(Commands::Theme { action }) => match action { ThemeCommands::List => list_themes() .map_err(error::XFetchError::Fatal) @@ -259,27 +342,62 @@ fn run(cli: Cli) -> Result<(), error::XFetchError> { } }, Some(Commands::Effects { action }) => match action { - EffectCommands::Install { path, repo, sha256 } => { - install_effect(&path, repo.as_deref(), sha256.as_deref()) - .map_err(error::XFetchError::Fatal) + EffectCommands::Install { + path, + repo, + sha256, + name, + manifest, + } => { + let direct = name.is_some() || manifest.is_some(); + let installed = if direct { + if repo.is_some() { + return Err(error::XFetchError::Fatal( + "--name/--manifest apply to direct wasm files or URLs; omit --repo" + .to_string(), + )); + } + addons::install::install_direct( + AddonKind::Effect, + &path, + sha256.as_deref(), + name.as_deref(), + manifest.as_deref(), + ) + .map_err(error::XFetchError::Fatal)? + } else { + install_effect(&path, repo.as_deref(), sha256.as_deref()) + .map_err(error::XFetchError::Fatal)?; + infer_name(AddonKind::Effect, &path, None).map_err(error::XFetchError::Fatal)? + }; + addons::report::print_install_guidance(AddonKind::Effect, &installed); + Ok(()) } - EffectCommands::List => { - list_effects() + EffectCommands::List { verbose } => { + let installed = list_effects().map_err(error::XFetchError::Fatal)?; + let config = load_config(cli.config.clone()); + let entries = installed + .into_iter() + .map(|(name, path)| AddonEntry::load(AddonKind::Effect, &name, &path, &config)) + .collect::>(); + addons::report::print_list(AddonKind::Effect, &entries, verbose); + Ok(()) + } + EffectCommands::Info { name } => { + let path = AddonKind::Effect + .installed_path(&name) + .or_else(|| effects::find_effect_binary(&name)) + .ok_or_else(|| { + error::XFetchError::Fatal(format!("Effect '{}' is not installed.", name)) + })?; + let config = load_config(cli.config.clone()); + let entry = AddonEntry::load(AddonKind::Effect, &name, &path, &config); + addons::report::print_info(&entry); + Ok(()) + } + EffectCommands::Search { query, json } => { + addons::catalog::print_search(AddonKind::Effect, query.as_deref(), json) .map_err(error::XFetchError::Fatal) - .map(|effects| { - if effects.is_empty() { - println!("No effects installed."); - println!( - "Effect directory: {}", - effects::default_effect_dir().display() - ); - } else { - println!("Installed effects:"); - for (name, path) in &effects { - println!(" {} ({})", name, path.display()); - } - } - }) } EffectCommands::Remove { name } => { remove_effect(&name).map_err(error::XFetchError::Fatal) diff --git a/src/ui.rs b/src/ui.rs index c74b6bf..b9dea7a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,7 +20,7 @@ mod win_daemon; mod x; #[cfg(unix)] pub use daemon::{draw_daemon, stop_daemon}; -pub use layout::is_known_layout; +pub use layout::{LAYOUT_NAMES, is_known_layout}; #[cfg(unix)] pub use live::{draw_live_daemon, stop_live_daemon}; #[cfg(windows)] diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 12dfb85..2710f11 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -81,6 +81,11 @@ mod tests { use super::*; use crate::config::Config; + #[test] + fn test_layout_names_include_generated_default() { + assert!(LAYOUT_NAMES.contains(&"section")); + } + #[test] fn test_get_content_lines_empty() { // Verify that the layout correctly handles an empty list of nodes without crashing diff --git a/src/update/github.rs b/src/update/github.rs index 372e8fc..c33ff16 100644 --- a/src/update/github.rs +++ b/src/update/github.rs @@ -45,6 +45,11 @@ struct WireAsset { size: u64, } +struct HttpResponse { + content_length: Option, + body: ureq::Body, +} + /// Small HTTP client with xfetch's user agent and optional auth. pub struct Client { agent: ureq::Agent, @@ -53,10 +58,12 @@ pub struct Client { impl Client { pub fn new() -> Result { - let agent = ureq::AgentBuilder::new() + let agent: ureq::Agent = ureq::Agent::config_builder() .user_agent(concat!("xfetch/", env!("CARGO_PKG_VERSION"))) - .redirects(5) - .build(); + .max_redirects(5) + .http_status_as_error(false) + .build() + .into(); let token = std::env::var("GH_TOKEN") .or_else(|_| std::env::var("GITHUB_TOKEN")) .ok() @@ -66,10 +73,13 @@ impl Client { /// Fetches a small JSON/text document with a bounded size. pub fn get_text(&self, url: &str, cap: usize, timeout: Duration) -> Result { - let response = self.request(url, timeout)?; + let mut response = self.request(url, timeout)?; let mut body = Vec::new(); response - .into_reader() + .body + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut body) .map_err(|err| format!("Failed to read '{}': {}", url, err))?; @@ -82,11 +92,8 @@ impl Client { /// Downloads a binary asset with a bounded size. #[cfg_attr(not(unix), allow(dead_code))] pub fn get_bytes(&self, url: &str, cap: usize) -> Result, String> { - let response = self.request(url, Duration::from_secs(300))?; - let declared = response - .header("content-length") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); + let mut response = self.request(url, Duration::from_secs(300))?; + let declared = response.content_length.unwrap_or(0); if declared > cap as u64 { return Err(format!( "Asset from '{}' is larger than the {} MiB limit", @@ -97,7 +104,10 @@ impl Client { let mut body = Vec::new(); response - .into_reader() + .body + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut body) .map_err(|err| format!("Failed to download '{}': {}", url, err))?; @@ -108,40 +118,47 @@ impl Client { } /// Sends a GET with the standard headers. - fn request(&self, url: &str, timeout: Duration) -> Result { + fn request(&self, url: &str, timeout: Duration) -> Result { let mut request = self .agent .get(url) - .timeout(timeout) - .set("Accept", "application/vnd.github+json"); + .config() + .timeout_global(Some(timeout)) + .build() + .header("Accept", "application/vnd.github+json"); if let Some(token) = &self.token { - request = request.set("Authorization", &format!("Bearer {}", token)); + request = request.header("Authorization", format!("Bearer {}", token)); } - request.call().map_err(|err| http_error(url, err)) - } -} - -/// Turns a ureq error into an actionable message. -fn http_error(url: &str, err: ureq::Error) -> String { - match err { - ureq::Error::Status(code, response) => { - if code == 403 { - format!( + let mut response = request + .call() + .map_err(|err| format!("Request to '{}' failed: {}", url, err))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + if status == 403 { + return Err(format!( "'{}' returned HTTP 403 (rate limit?). Set GH_TOKEN or try again later.", url - ) - } else { - let body = response - .into_string() - .unwrap_or_default() - .chars() - .take(160) - .collect::(); - format!("'{}' returned HTTP {}: {}", url, code, body) + )); } + let body = response + .body_mut() + .with_config() + .limit(160) + .read_to_string() + .unwrap_or_default(); + return Err(format!("'{}' returned HTTP {}: {}", url, status, body)); } - ureq::Error::Transport(err) => format!("Request to '{}' failed: {}", url, err), + + let content_length = response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + Ok(HttpResponse { + content_length, + body: response.into_body(), + }) } } diff --git a/src/wasm/host/http.rs b/src/wasm/host/http.rs index a8122e9..0983c30 100644 --- a/src/wasm/host/http.rs +++ b/src/wasm/host/http.rs @@ -38,40 +38,54 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult { let mut current = Url::parse(url) .map_err(|err| HostCallError::failed(format!("invalid URL '{}': {}", url, err)))?; - let agent = ureq::AgentBuilder::new() - .redirects(0) - .timeout(timeout) + let config = ureq::Agent::config_builder() + .max_redirects(0) + .timeout_global(Some(timeout)) + .http_status_as_error(false) .build(); + let agent: ureq::Agent = config.into(); for _ in 0..=MAX_REDIRECTS { let started = Instant::now(); - let mut request = agent.request(&method, current.as_str()); - for (name, value) in &headers { - request = request.set(name, value); - } - + let build = || { + let mut builder = ureq::http::Request::builder() + .method(method.as_str()) + .uri(current.as_str()); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_str()); + } + builder + }; let response = match body.as_deref() { - Some(bytes) => request.send_bytes(bytes), - None => request.call(), + Some(bytes) => { + let request = build().body(bytes).map_err(|err| { + HostCallError::failed(format!("invalid HTTP request: {}", err)) + })?; + agent.run(request) + } + None => { + let request = build().body(()).map_err(|err| { + HostCallError::failed(format!("invalid HTTP request: {}", err)) + })?; + agent.run(request) + } }; let response = match response { Ok(response) => response, - // Non-2xx statuses are still valid responses: return them. - Err(ureq::Error::Status(_, response)) => response, - Err(ureq::Error::Transport(err)) => { - return Err(transport_error(err.to_string(), started, timeout)); - } + Err(err) => return Err(transport_error(err.to_string(), started, timeout)), }; - let status = response.status(); + let status = response.status().as_u16(); if (300..400).contains(&status) { let location = response - .header("location") + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) .ok_or_else(|| { HostCallError::failed("redirect response without a Location header") - })? - .to_string(); + })?; let next = current.join(&location).map_err(|err| { HostCallError::failed(format!("invalid redirect target '{}': {}", location, err)) })?; @@ -92,20 +106,27 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult { } /// Collects response metadata and the capped body. -fn read_response(response: ureq::Response, ctx: &HostContext) -> HostCallResult { - let status = response.status(); +fn read_response( + mut response: ureq::http::Response, + ctx: &HostContext, +) -> HostCallResult { + let status = response.status().as_u16(); let mut headers = Vec::new(); - for name in response.headers_names() { - for value in response.all(&name) { - headers.push(json!([name, value])); - } + for (name, value) in response.headers() { + headers.push(json!([ + name.as_str(), + String::from_utf8_lossy(value.as_bytes()).to_string() + ])); } let cap = ctx.policy.host_call_bytes; let mut bytes = Vec::new(); response - .into_reader() + .body_mut() + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut bytes) .map_err(|err| HostCallError::failed(format!("failed to read response body: {}", err)))?; @@ -220,4 +241,37 @@ mod tests { HostErrorKind::Failed | HostErrorKind::Timeout )); } + + #[test] + fn successful_request_returns_status_headers_and_body() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Type: text/plain\r\nX-Test: yes\r\n\r\nhello", + ) + .expect("write"); + }); + + let url = format!("http://127.0.0.1:{port}/"); + let allow = format!(r#"{{ "capabilities": {{ "http": {{ "allow": ["{url}"] }} }} }}"#); + let ctx = context(&allow); + let result = request(&json!({ "url": url, "timeout_ms": 2000 }), &ctx).expect("request"); + server.join().expect("server"); + + assert_eq!(result["status"], 200); + assert_eq!(result["body_base64"], "aGVsbG8="); + assert!(result["headers"].as_array().is_some_and(|headers| { + headers + .iter() + .any(|header| header[0] == "x-test" && header[1] == "yes") + })); + } } diff --git a/src/wasm/install.rs b/src/wasm/install.rs index 8f5b7ec..62aae99 100644 --- a/src/wasm/install.rs +++ b/src/wasm/install.rs @@ -282,14 +282,19 @@ pub fn download_and_install( println!("Downloading wasm {} '{}' from {}...", label, name, url); - let response = ureq::get(url) - .timeout(Duration::from_secs(120)) + let mut response = ureq::get(url) + .config() + .timeout_global(Some(Duration::from_secs(120))) + .build() .call() .map_err(|err| format!("Failed to download '{}': {}", url, err))?; let mut bytes = Vec::new(); response - .into_reader() + .body_mut() + .with_config() + .limit(DOWNLOAD_LIMIT + 1) + .reader() .take(DOWNLOAD_LIMIT + 1) .read_to_end(&mut bytes) .map_err(|err| format!("Failed to read '{}': {}", url, err))?;