diff --git a/.actrc b/.actrc
new file mode 100644
index 0000000..d515c69
--- /dev/null
+++ b/.actrc
@@ -0,0 +1 @@
+--container-architecture linux/amd64
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..fb5a905
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,28 @@
+name: Build
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build-linux:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ cache: ${{ !env.ACT }}
+
+ - name: Build for Linux
+ run: cargo build --release --target x86_64-unknown-linux-gnu --verbose
+
+ - name: Upload Linux binary
+ if: ${{ !env.ACT }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: pathfinder-linux-x64
+ path: target/x86_64-unknown-linux-gnu/release/pathFinder
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..537294b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,25 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache dependencies
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Run tests
+ run: cargo test
diff --git a/.gitignore b/.gitignore
index 06dbe88..d81bd3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
+# Rust/Cargo
+target/
+**/*.rs.bk
+*.pdb
+
# Python-generated files
__pycache__/
*.py[oc]
@@ -12,3 +17,6 @@ wheels/
# IDE
.vscode
+# OS files
+.DS_Store
+*~
diff --git a/.python-version b/.python-version
deleted file mode 100644
index 24ee5b1..0000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.13
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d17abde
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,23 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- Test coverage and GitHub action to run them
+
+### Changed
+
+- Removed unwanted logging of RSE paths
+- Don't make site capabilities API call unless file isn't found in local RSE mount
+
+## v1.0.0
+
+### Added
+
+- Initial Rust implementation
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..d84455c
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2833 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "ascii-canvas"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6"
+dependencies = [
+ "term",
+]
+
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "async-attributes"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-global-executor"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "blocking",
+ "futures-lite",
+ "once_cell",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener 5.4.1",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-object-pool"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c"
+dependencies = [
+ "async-std",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener 5.4.1",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-std"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
+dependencies = [
+ "async-attributes",
+ "async-channel 1.9.0",
+ "async-global-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "crossbeam-utils",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-lite",
+ "gloo-timers",
+ "kv-log-macro",
+ "log",
+ "memchr",
+ "once_cell",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "basic-cookies"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7"
+dependencies = [
+ "lalrpop",
+ "lalrpop-util",
+ "regex",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "ena"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener 5.4.1",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.0",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http 1.4.0",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "httpmock"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b"
+dependencies = [
+ "assert-json-diff",
+ "async-object-pool",
+ "async-std",
+ "async-trait",
+ "base64 0.21.7",
+ "basic-cookies",
+ "crossbeam-utils",
+ "form_urlencoded",
+ "futures-util",
+ "hyper 0.14.32",
+ "lazy_static",
+ "levenshtein",
+ "log",
+ "regex",
+ "serde",
+ "serde_json",
+ "serde_regex",
+ "similar",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http 1.4.0",
+ "hyper 1.8.1",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper 1.8.1",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "hyper 1.8.1",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2 0.6.2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+[[package]]
+name = "iri-string"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "js-sys"
+version = "0.3.88"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kv-log-macro"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "lalrpop"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca"
+dependencies = [
+ "ascii-canvas",
+ "bit-set",
+ "ena",
+ "itertools",
+ "lalrpop-util",
+ "petgraph",
+ "pico-args",
+ "regex",
+ "regex-syntax",
+ "string_cache",
+ "term",
+ "tiny-keccak",
+ "unicode-xid",
+ "walkdir",
+]
+
+[[package]]
+name = "lalrpop-util"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "levenshtein"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "libredox"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+dependencies = [
+ "value-bag",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl"
+version = "0.10.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pathFinder"
+version = "1.0.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "dirs",
+ "httpmock",
+ "libc",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pico-args"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "encoding_rs",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.8.1",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_regex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf"
+dependencies = [
+ "regex",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.1",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "term"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
+dependencies = [
+ "dirs-next",
+ "rustversion",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.49.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "value-bag"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.88"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+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.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+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",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[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 = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..263a3e8
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "pathFinder"
+version = "1.0.0"
+edition = "2021"
+
+[[bin]]
+name = "pathFinder"
+path = "src/main.rs"
+
+[dependencies]
+clap = { version = "4.5", features = ["derive"] }
+reqwest = { version = "0.12", features = ["json", "blocking"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+anyhow = "1.0"
+thiserror = "1.0"
+tokio = { version = "1.40", features = ["full"] }
+regex = "1.10"
+dirs = "5.0"
+libc = "0.2"
+
+[dev-dependencies]
+httpmock = "0.7"
+tempfile = "3"
diff --git a/README.md b/README.md
index 2dd7f67..36f52b8 100644
--- a/README.md
+++ b/README.md
@@ -1,127 +1,71 @@
-# pathFinder #
+# Path Finder
-pathFinder is a tool for mounting SKA data on Slurm clusters without copying the data locally.
+A Rust implementation of the SKA path finder tool for authentication, locating & mounting data from the SKA storage system within a Slurm login host.
-It allows the Scientist to specify which files, identified from the Science Gateway, they want to mount while keeping the files secure and owned by them.
-## TODO Development
+## Overview
-- [ ] Always check site capabilities to ensure that the data is staged to your local RSE.
- - [ ] Work out whether we need to check for tier 0.
-- [ ] Tidy up the code around checking the response from the DM API `data/locate` request.
-- [ ] Use this script to perform the data mount.
-- [ ] Investigate whether the data can be specified using the IVO URI.
+This project replaces the Python/Bash-based path finder (see git history) with a portable Rust implementation. It provides a single binary and an RPM installer.
-## HOW TO Try this script during development
+## Features
-1. Ensure you have installed `uv` -
+- OAuth2 device code flow authentication
+- Data location lookup via Data Management API
+- Site capabilities verification via Site Capabilities API
+- Secure data mounting with proper permissions
- uv --version
+## Building
- NB., you can use other dependency managers which use the `pyproject.toml` - e.g. `poetry`. Hint: `uv` is way faster!
+The binary and RPM are built and published on a GitHub release.
-2. Set your Data Management API Access Token:
+## Installation
- 1. Navigate to
- 2. Click your initials badge in the top-right and select "View Token"
- 3. Copy the "Data management access token" string
- 4. Set the DATA_MANAGEMENT_ACCESS_TOKEN environment variable in your shell:
+1. Find the latest release in GitHub, and copy the URL of the published RPM.
- export DATA_MANAGEMENT_ACCESS_TOKEN=[PASTED STRING]
+2. On the Slurm login node:
-3. Run the script while `uv` takes care of the dependencies for you:
+ sudo dnf install [URL_TO_RELEASE_ARTEFACT]
- uv run path_finder/path_finder.py
+## Usage
-## USE CASE
-
-Two methods are planned, interactive and a workflow managed by the Science Gateway via prepareData.
-
-This documentation covers the prerequisites to setup on the underlying configuration on a Slurm cluster and the installation of the pathFinder tool.
-
-
-## Pre-requisites ##
-
-The following requirements must be met.
-
-(Note these are for Rocky 9.x releases and have not been tested on RHEL 10.x or Ubuntu)
-
- - CRB Enabled
- - RHEL EPEL (Extra Packages)
- - BindFS
- - Ceph Common
-
-## Server Side Configuration ##
-
-The configuration is only required on the Login node of your Slurm cluster, this assumes that all your user home directories are CephFS/NFS mount points.
-
-If you already have EPEL enabled you can skip the next 2 steps.
-
-1. Enable CRB
+With OAuth2 authentication (recommended):
-```
-crb status
-crb enable
+```bash
+sudo pathFinder \
+ --namespace daac \
+ --file_name pi24_test_run_1_cleaned.fits
```
-2. Install EPEL
-```
-sudo dnf install epel-release
-sudo dnf repolist
-```
+With environment variables (for automation):
-3. Configure your Ceph Keyring
+```bash
+export DATA_MANAGEMENT_ACCESS_TOKEN="your_token_here"
+export SITE_CAPABILITIES_ACCESS_TOKEN="your_token_here"
-```
-vi /etc/ceph/ceph.client.rucio_prod_ro.keyring
-```
-Add your Access key.
-```
-[client.rucio_prod_ro]
-key = ****************************
+sudo pathFinder \
+ --namespace daac \
+ --file_name pi24_test_run_1_cleaned.fits \
+ --no-login
```
-4. Add an /etc/fstab entry
-```
-10.4.200.9:6789,10.4.200.13:6789,10.4.200.13:6789,10.4.200.17:6789,10.4.200.25:6789,10.4.200.26:6789:/volumes/_nogroup/a8af40e8-6412-44da-ad08-3731fdf19258/4945e5c2-aab7-4416-9b75-666f2af512d7 /skadata ceph name=rucio_prod_ro,x-systemd.device-timeout=30,x-systemd.mount-timeout=30,noatime,_netdev,ro,nodev,nosuid 0 2
-```
-5. Mount the /skadata mountpoint.
+**Note**: The tool will automatically check if the file exists locally at `/skadata`. If the file is not found locally, it will display the sites where the file is available and prompt you to ensure the data has been staged to your local site before mounting.
-Note that we use bindfs here as well so all files under `/skadata` are presented as `root root` for owner and group and hides the real owner **uid/gid** which would typically be the xrootd, Webdav & Storm user uid/gid.
-```
-mount /skadata
-systemctl daemon-reload
-bindfs -u root -g root /skadata /skadata
-```
+## Architecture
-6. Create a mountpoint, this MUST be owned by root with permissions of 550.
-```
-sudo mkdir /skadata
-sudo chmod 550 /skadata
-```
+### Modules
-7. Add a sudoers file to control access to the pathfinder tool.
-```
-vi /etc/sudoers.d/pathFinder
-```
-Using group `pathfinder` for group access for users you want to give access to.
-```
-%pathfinder ALL = NOPASSWD: /usr/bin/pathfinder, /usr/bin/pathFinder
-```
+- **main.rs** - Main path finder CLI logic
+- **api_client.rs** - HTTP client for Data Management and Site Capabilities APIs
+- **oauth2_auth.rs** - OAuth2 device code flow implementation with token caching
+- **models.rs** - Data structures for API responses (sites, nodes, storage areas, data locations)
+- **mount.rs** - Mount/unmount utility for data access
-8. Add the local groups.
-```
-groupadd pathfinder
-```
+## System Requirements
-9. Add or update the local users to their corresponding group.
-```
-usermod -a -G pathfinder sm2921
-```
-10. Install the pathFinder package.
-```
-dnf install https://github.com/uksrc/pathFinder/releases/download/v1.0.0/pathfinder-1.0.0-1.x86_64.rpm
-```
+- **bindfs** - FUSE filesystem for permission remapping
+- **sudo** - Required for mount operations
+- **mountpoint** - Used to verify mount status
+The system needs to have the local RSE mounted at `/skadata` as a 700 mount owned by root:root. TODO: Ensure the program checks this and reports correctly if the share is not present.
+A sudoers file needs to be added to allow members for the pathfinders group sudo privileges to the executable - TODO: Add this to the RPM.
-The RSE location will be used to run a `bindfs` command on the parent folder to mount this into the user's `~/.skadata/` directory, setting the user and group to the current user. The specific file from the parent folder will then be used to `mount --bind` that file to `~/skadata/[FILE_NAME]`.
diff --git a/bash_scripts/pathFinder-nobind.sh b/bash_scripts/pathFinder-nobind.sh
deleted file mode 100644
index 19cf42b..0000000
--- a/bash_scripts/pathFinder-nobind.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/bash
-OPTION=$1
-FITS=$2
-SUDO_GROUP=$3
-
-FITS_FILE=$(echo $FITS | awk -F"/" '{print $NF}')
-FITS_PATH=$(echo $FITS | awk -F"/" 'BEGIN {OFS = FS} {$(NF--)=""; print}')
-BIND_PATH=$(echo $FITS_FILE | awk -F"." '{print $1}')
-
-if [ "$OPTION" = "--mount" ]; then
- # Check if the .binds target is already a mount to avoid cyclical mounts
- if mountpoint -q "/home/$SUDO_USER/projects/$BIND_PATH"; then
- echo "Error: /home/$SUDO_USER/projects/$BIND_PATH is already mounted; aborting to avoid cyclic mounts."
- exit 1
- else
- # mkdir -p "/home/$SUDO_USER/.binds/$BIND_PATH"
- # chown -R "$SUDO_USER:$SUDO_USER" "/home/$SUDO_USER/.binds/"
- # chmod 600 "/home/$SUDO_USER/.binds/$BIND_PATH"
-
- mkdir -p "/home/$SUDO_USER/projects/$BIND_PATH"
- # touch "/home/$SUDO_USER/projects/$FITS_FILE"
- chown -R "$SUDO_USER:$SUDO_USER" "/home/$SUDO_USER/projects/"
- chmod 500 "/home/$SUDO_USER/projects/$BIND_PATH"
- bindfs --perms=0700 --force-user="$SUDO_USER" --force-group="$SUDO_USER" "/skadata/$SUDO_GROUP/$FITS_PATH" "/home/$SUDO_USER/projects/$BIND_PATH"
- # mount --bind "/home/$SUDO_USER/.binds/$BIND_PATH/$FITS_FILE" "/home/$SUDO_USER/projects/$FITS_FILE"
- fi
- # Verify the mount was successful
- if mountpoint -q "/home/$SUDO_USER/projects/$BIND_PATH"; then
- echo "Mount verification successful: $BIND_PATH is mounted at /home/$SUDO_USER/projects/$BIND_PATH"
- else
- echo "Error: Mount verification failed for $BIND_PATH at /home/$SUDO_USER/projects/$BIND_PATH"
- exit 1
- fi
-elif [ "$OPTION" = "--unmount" ]; then
- umount "/home/$SUDO_USER/projects/$BIND_PATH"
- # umount "/home/$SUDO_USER/.binds/$BIND_PATH"
- # rm -rf "/home/$SUDO_USER/.binds/$BIND_PATH"
- rm -f "/home/$SUDO_USER/projects/$BIND_PATH"
- echo "Unmounted $FITS_FILE from /home/$SUDO_USER/projects/$BIND_PATH"
-else
- echo "Usage: $0 [--mount|--unmount] "
- exit 1
-fi
diff --git a/bash_scripts/pathFinder.py b/bash_scripts/pathFinder.py
deleted file mode 100644
index a438d96..0000000
--- a/bash_scripts/pathFinder.py
+++ /dev/null
@@ -1,86 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-import os
-import sys
-import subprocess
-
-def run(cmd, check=True):
- res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
- if check and res.returncode != 0:
- print(f"Error running: {' '.join(cmd)}\n{res.stderr.strip()}", file=sys.stderr)
- sys.exit(res.returncode)
- return res
-
-def is_mountpoint(path):
- return subprocess.run(['mountpoint', '-q', path]).returncode == 0
-
-def main():
- p = argparse.ArgumentParser(prog=os.path.basename(__file__), add_help=False)
- p.add_argument('option', nargs='?')
- p.add_argument('fits', nargs='?')
- p.add_argument('sudo_group', nargs='?')
- args = p.parse_args()
-
- if args.option not in ('--mount', '--unmount'):
- print(f"Usage: {os.path.basename(__file__)} [--mount|--unmount] ")
- sys.exit(1)
-
- if not args.fits or not args.sudo_group:
- print("Error: missing or ", file=sys.stderr)
- sys.exit(1)
-
- sudo_user = os.environ.get('SUDO_USER')
- if not sudo_user:
- print("Error: SUDO_USER not set. Run via sudo.", file=sys.stderr)
- sys.exit(1)
-
- fits = args.fits
- fits_file = os.path.basename(fits)
- fits_path = os.path.dirname(fits) # may be ''
- bind_name = os.path.splitext(fits_file)[0]
-
- home = f"/home/{sudo_user}"
- bind_dir = os.path.join(home, '.binds', bind_name)
- projects_dir = os.path.join(home, 'projects')
- projects_file = os.path.join(projects_dir, fits_file)
- skadata_src = os.path.join('/skadata', args.sudo_group, fits_path)
-
- if args.option == '--mount':
- # avoid cyclic mounts
- if is_mountpoint(bind_dir):
- print(f"Error: {bind_dir} is already mounted; aborting to avoid cyclic mounts.", file=sys.stderr)
- sys.exit(1)
-
- os.makedirs(bind_dir, exist_ok=True)
- os.makedirs(projects_dir, exist_ok=True)
-
- # touch project file
- open(projects_file, 'a').close()
-
- # set ownership and perms
- run(['chown', '-R', f'{sudo_user}:{sudo_user}', os.path.join(home, '.binds')])
- run(['chmod', '600', bind_dir]) # file-like perms in original; keep simple
- run(['chown', '-R', f'{sudo_user}:{sudo_user}', projects_dir])
- run(['chmod', '500', projects_file])
-
- # bindfs then bind mount
- run(['bindfs', '--perms=0700', f'--force-user={sudo_user}', f'--force-group={sudo_user}', skadata_src, bind_dir])
- run(['mount', '--bind', os.path.join(bind_dir, fits_file), projects_file])
-
- # verify
- if is_mountpoint(projects_file):
- print(f"Mount verification successful: {fits_file} is mounted at {projects_file}")
- else:
- print(f"Error: Mount verification failed for {fits_file} at {projects_file}", file=sys.stderr)
- sys.exit(1)
-
- elif args.option == '--unmount':
- run(['umount', projects_file], check=False)
- run(['umount', bind_dir], check=False)
- run(['rm', '-rf', bind_dir])
- run(['rm', '-f', projects_file])
- print(f"Unmounted {fits_file} from {projects_file}")
-
-if __name__ == '__main__':
- main()
-# EOF
\ No newline at end of file
diff --git a/bash_scripts/pathFinder.sh b/bash_scripts/pathFinder.sh
deleted file mode 100644
index 7b10d69..0000000
--- a/bash_scripts/pathFinder.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/bin/bash
-OPTION=$1
-FITS=$2
-SUDO_GROUP=$3
-
-FITS_FILE=$(echo $FITS | awk -F"/" '{print $NF}')
-FITS_PATH=$(echo $FITS | awk -F"/" 'BEGIN {OFS = FS} {$(NF--)=""; print}')
-FITS_GROUP=$(echo $FITS | awk -F"/" '{print $1}')
-BIND_PATH=$(echo $FITS_FILE | awk -F"." '{print $1}')
-
-if [ "$OPTION" = "--mount" ]; then
- # Check if the .binds target is already a mount to avoid cyclical mounts
- if mountpoint -q "/home/$SUDO_USER/.binds/$FITS_PATH"; then
- echo "Error: /home/$SUDO_USER/.binds/$FITS_PATH is already mounted; aborting to avoid cyclic mounts."
- exit 1
- else
- # Verify that the provided sudo group matches the namespace
- if [ $FITS_GROUP != "$SUDO_GROUP" ]; then
- echo "Error: Provided sudo group '$SUDO_GROUP' does not match fits group '$FITS_GROUP'; aborting."
- exit 1
- else
- mkdir -p "/home/$SUDO_USER/.binds/$BIND_PATH"
- chown -R "$SUDO_USER:$SUDO_USER" "/home/$SUDO_USER/.binds/"
- chmod 600 "/home/$SUDO_USER/.binds/$BIND_PATH"
-
- mkdir -p "/home/$SUDO_USER/projects"
- touch "/home/$SUDO_USER/projects/$FITS_FILE"
- chown -R "$SUDO_USER:$SUDO_USER" "/home/$SUDO_USER/projects/"
- chmod 600 "/home/$SUDO_USER/projects/$FITS_FILE"
- bindfs --perms=0700 --force-user="$SUDO_USER" --force-group="$SUDO_USER" "/skadata/$FITS_PATH" "/home/$SUDO_USER/.binds/$BIND_PATH"
- mount --bind "/home/$SUDO_USER/.binds/$BIND_PATH/$FITS_FILE" "/home/$SUDO_USER/projects/$FITS_FILE"
- fi
- fi
- # Verify the mount was successful
- if mountpoint -q "/home/$SUDO_USER/projects/$FITS_FILE"; then
- echo "Mount verification successful: $FITS_FILE is mounted at /home/$SUDO_USER/projects/$FITS_FILE"
- else
- echo "Error: Mount verification failed for $FITS_FILE at /home/$SUDO_USER/projects/$FITS_FILE"
- exit 1
- fi
-elif [ "$OPTION" = "--unmount" ]; then
- umount "/home/$SUDO_USER/projects/$FITS_FILE"
- umount "/home/$SUDO_USER/.binds/$BIND_PATH"
- rm -rf "/home/$SUDO_USER/.binds/$BIND_PATH"
- rm -f "/home/$SUDO_USER/projects/$FITS_FILE"
- echo "Unmounted $FITS_FILE from /home/$SUDO_USER/projects/$FITS_FILE"
-else
- echo "Usage: $0 [--mount|--unmount] "
- exit 1
-fi
diff --git a/path_finder/__init__.py b/path_finder/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/path_finder/models/__init__.py b/path_finder/models/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/path_finder/models/data_management.py b/path_finder/models/data_management.py
deleted file mode 100644
index 448cf6e..0000000
--- a/path_finder/models/data_management.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from pydantic import BaseModel, TypeAdapter
-
-
-class DataLocation(BaseModel):
- identifier: str
- associated_storage_area_id: str
- replicas: list[str]
-
-
-DataLocationAPIResponse = TypeAdapter(list[DataLocation])
diff --git a/path_finder/models/site_capabilities.py b/path_finder/models/site_capabilities.py
deleted file mode 100644
index eacd9ad..0000000
--- a/path_finder/models/site_capabilities.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import itertools
-from pydantic import BaseModel, Field, TypeAdapter
-
-# Type aliases to aid readability of model classes
-SiteName = str
-NodeName = str
-StorageAreaID = str
-SiteNameToStorageAreas = dict[SiteName, list["StorageArea"]]
-NodeNameToSiteStorageAreas = dict[NodeName, SiteNameToStorageAreas]
-StorageAreaIDToNodeAndSite = dict[StorageAreaID, tuple[NodeName, SiteName]]
-
-
-class StorageArea(BaseModel):
- id: StorageAreaID
- name: str = Field(default="")
- type: str = Field(default="")
- relative_path: str = Field(default="")
- tier: int | None = Field(default=None)
-
-
-class Storage(BaseModel):
- id: str
- name: str = Field(default="")
- areas: list[StorageArea]
-
-
-class Site(BaseModel):
- id: str
- name: SiteName
- country: str
- storages: list[Storage]
-
- @property
- def storage_areas(self) -> list[StorageArea]:
- """Collate all storage areas from all storages in this site."""
- return list(
- itertools.chain.from_iterable(storage.areas for storage in self.storages)
- )
-
-
-class Node(BaseModel):
- name: NodeName
- description: str = Field(default="")
- sites: list[Site] = Field(default=[])
-
- @property
- def storage_areas(self) -> SiteNameToStorageAreas:
- """Construct a mapping of site names to their storage areas."""
- return {site.name: [area for area in site.storage_areas] for site in self.sites}
-
- @property
- def storage_area_id_to_site_name(self) -> StorageAreaIDToNodeAndSite:
- """Construct a mapping of storage area IDs to their corresponding node and site names."""
- mapping: dict[str, tuple[NodeName, SiteName]] = {}
- for site_name, storage_areas in self.storage_areas.items():
- mapping.update({area.id: (self.name, site_name) for area in storage_areas})
- return mapping
-
-
-# Define an entity which represents the API response containing a list of nodes
-NodesAPIResponse = TypeAdapter(list[Node])
-SitesAPIResponse = TypeAdapter(list[Site])
-
-def get_all_node_storage_areas(nodes: list[Node]) -> StorageAreaIDToNodeAndSite:
- """Fetch all nodes and construct a mapping of storage area IDs to their corresponding node and site names.
-
- Returns:
- StorageAreaIDToNodeAndSite: A mapping of storage area IDs to their corresponding node
- and site names.
- """
- storage_area_mapping: StorageAreaIDToNodeAndSite = {}
- for node in nodes:
- storage_area_mapping.update(node.storage_area_id_to_site_name)
- return storage_area_mapping
diff --git a/path_finder/oauth2_auth.py b/path_finder/oauth2_auth.py
deleted file mode 100644
index e5571b8..0000000
--- a/path_finder/oauth2_auth.py
+++ /dev/null
@@ -1,350 +0,0 @@
-#!/usr/bin/env python3
-"""
-OAuth2 Device Code Flow authentication for SKA APIs.
-
-This module implements OAuth2 device code flow to authenticate users and obtain
-access tokens for the Data Management and Site Capabilities APIs.
-"""
-
-import json
-import os
-import re
-import time
-from datetime import datetime, timedelta
-from pathlib import Path
-import requests
-
-
-# Authentication endpoints
-AUTHN_BASE_URL = "https://authn.srcnet.skao.int/api/v1"
-DATA_MANAGEMENT = "data-management-api"
-SITE_CAPABILITIES = "site-capabilities-api"
-
-
-class OAuth2AuthenticationError(Exception):
- """Exception raised for OAuth2 authentication errors."""
-
- pass
-
-
-def authenticate(use_cache: bool = True) -> dict[str, str]:
- """Complete OAuth2 device code flow and obtain all required API tokens.
-
- Args:
- use_cache: Whether to use cached tokens if available (default: True).
-
- Returns:
- Dict containing:
- - data_management_token: Token for Data Management API
- - site_capabilities_token: Token for Site Capabilities API
-
- Raises:
- OAuth2AuthenticationError: If authentication fails at any step.
- """
-
- # Try to load from cache first
- if use_cache:
- cached_tokens = load_tokens_from_cache()
- if cached_tokens:
- return cached_tokens
-
- # Perform full authentication flow
- device_info = initiate_device_code_flow()
- display_user_instructions(device_info)
-
- device_code = device_info["device_code"]
- interval = int(device_info.get("interval", 5))
- auth_token = poll_for_authentication(device_code, interval)
-
- # Get API-specific tokens
- dm_token = exchange_token_for_api_token(auth_token, DATA_MANAGEMENT)
- sc_token = exchange_token_for_api_token(auth_token, SITE_CAPABILITIES)
-
- tokens = {"data_management_token": dm_token, "site_capabilities_token": sc_token}
-
- # Save to cache (default expiration: 1 hour)
- save_tokens_to_cache(tokens, expires_in=3600)
-
- return tokens
-
-
-def save_tokens_to_cache(tokens: dict[str, str], expires_in: int = 3600) -> None:
- """Save authentication tokens to cache file.
-
- Args:
- tokens: Dictionary containing authentication tokens.
- expires_in: Token expiration time in seconds (default: 1 hour).
- """
- cache_path = get_token_cache_path()
-
- # Calculate expiration time
- expiration = (datetime.now() + timedelta(seconds=expires_in)).isoformat()
-
- cache_data = {"tokens": tokens, "expires_at": expiration}
-
- # Write to cache with secure permissions
- cache_path.write_text(json.dumps(cache_data, indent=2))
- os.chmod(cache_path, 0o600) # Read/write for owner only
- print(f"Tokens cached until {expiration}")
-
-
-def load_tokens_from_cache() -> dict[str, str] | None:
- """Load authentication tokens from cache if valid.
-
- Returns:
- Dictionary containing tokens if valid, None if expired or not found.
- """
- cache_path = get_token_cache_path()
-
- if not cache_path.exists():
- return None
-
- try:
- cache_data = json.loads(cache_path.read_text())
-
- # Check if tokens are expired
- expires_at = datetime.fromisoformat(cache_data["expires_at"])
- if datetime.now() >= expires_at:
- print("Cached tokens expired")
- return None
-
- print("Using cached tokens")
- return cache_data["tokens"]
-
- except (json.JSONDecodeError, KeyError, ValueError) as e:
- print(f"Invalid cache file: {e}")
- return None
-
-
-def get_token_cache_path() -> Path:
- """Get the path to the token cache file.
-
- Returns:
- Path to the token cache file in user's config directory.
- """
- config_dir = Path.home() / ".config" / "path-finder"
- config_dir.mkdir(parents=True, exist_ok=True)
- return config_dir / "tokens.json"
-
-
-def initiate_device_code_flow() -> dict[str, str]:
- """Initiate the OAuth2 device code flow.
-
- Returns:
- Dict containing:
- - device_code: Code to use for polling
- - user_code: Code for user to enter
- - verification_uri: URL for user to visit
- - expires_in: Seconds until codes expire
- - interval: Polling interval in seconds
-
- Raises:
- OAuth2AuthenticationError: If the request fails.
- """
- try:
- # Request device and user codes from authn service
- response = requests.get(
- f"{AUTHN_BASE_URL}/login/device",
- timeout=10,
- )
- response.raise_for_status()
- return response.json()
- except requests.exceptions.RequestException as e:
- raise OAuth2AuthenticationError(f"Failed to initiate device code flow: {e}")
-
-
-def display_user_instructions(device_info: dict[str, str]) -> None:
- """Display instructions for the user to authenticate.
-
- Args:
- verification_uri: The URL the user should visit.
- user_code: The code the user should enter.
- """
- verification_uri = device_info["verification_uri"]
- user_code = device_info["user_code"]
- print(
- f"\nACTION REQUIRED:\n Open this URL in a browser and authenticate: {verification_uri}?user_code={user_code}"
- )
- print("\nWaiting for authentication (timeout: 5 minutes)...")
-
-
-def poll_for_authentication(
- device_code: str, interval: int = 5, timeout: int = 300
-) -> str:
- """Poll the authorization server for the authorization code.
-
- Args:
- device_code: The device code from the initial request.
- interval: Polling interval in seconds.
- timeout: Maximum time to poll in seconds.
-
- Returns:
- The authorization code.
-
- Raises:
- OAuth2AuthenticationError: If polling fails or times out.
- """
- start_time = time.time()
-
- while time.time() - start_time < timeout:
- try:
- response = requests.get(
- f"{AUTHN_BASE_URL}/token",
- params={"device_code": device_code},
- timeout=10,
- )
-
- if response.status_code == 200:
- token_data = response.json()
- # authn device flow returns access_token directly
- access_token_data = token_data.get("token")
- if not access_token_data:
- raise OAuth2AuthenticationError(
- f"No access_token in response. Received: {token_data.keys()}"
- )
- return access_token_data.get("access_token")
-
- # Parse error response - API wraps IAM errors in 'detail' field
- error_data = response.json()
- error, error_description = parse_wrapped_error_response(error_data)
-
- if error == "authorization_pending":
- time.sleep(interval)
- continue
- elif error == "slow_down":
- interval += 5
- time.sleep(interval)
- continue
- elif error == "expired_token":
- raise OAuth2AuthenticationError(
- "Device code expired. Please try again."
- )
- elif error == "access_denied":
- raise OAuth2AuthenticationError("User denied authorization.")
- else:
- error_msg = f"Authorization error: {error}"
- if error_description:
- error_msg += f" - {error_description}"
- raise OAuth2AuthenticationError(error_msg)
-
- except requests.exceptions.RequestException as e:
- raise OAuth2AuthenticationError(f"Failed to poll for authorization: {e}")
-
- raise OAuth2AuthenticationError("Authorization timeout. Please try again.")
-
-
-def parse_wrapped_error_response(error_data: dict) -> tuple[str | None, str | None]:
- """Parse error response that may be wrapped by the API.
-
- Args:
- error_data: The JSON error response from the API.
-
- Returns:
- Tuple of (error, error_description).
- """
- error = None
- error_description = None
-
- if "detail" in error_data:
- # Extract JSON from "response: {...}" pattern in detail string
- detail = error_data["detail"]
- match = re.search(r"response:\s*(\{.*\})\s*$", detail)
- if match:
- try:
- # Parse the embedded JSON
- embedded_json = json.loads(match.group(1))
- error = embedded_json.get("error")
- error_description = embedded_json.get("error_description")
- except json.JSONDecodeError:
- pass
-
- # Fallback to direct error field if not wrapped
- if not error:
- error = error_data.get("error")
- error_description = error_data.get("error_description")
-
- return error, error_description
-
-
-def exchange_code_for_auth_token(code: str) -> str:
- """Exchange authorization code for authentication token.
-
- Args:
- code: The authorization code from the device flow.
-
- Returns:
- The authentication token.
-
- Raises:
- OAuth2AuthenticationError: If the exchange fails.
- """
- try:
- response = requests.get(
- f"{AUTHN_BASE_URL}/token", params={"code": code}, timeout=10
- )
- response.raise_for_status()
-
- token_data = response.json()
- auth_token = token_data.get("access_token") or token_data.get("token")
-
- if not auth_token:
- raise OAuth2AuthenticationError("No access token in response")
-
- return auth_token
-
- except requests.exceptions.RequestException as e:
- raise OAuth2AuthenticationError(f"Failed to exchange code for auth token: {e}")
-
-
-def exchange_token_for_api_token(auth_token: str, api_name: str) -> str:
- """Exchange authentication token for a specific API token.
-
- Args:
- auth_token: The authentication token from the previous step.
- api_name: The API name ('data-management' or 'site-capabilities').
-
- Returns:
- The API-specific access token.
-
- Raises:
- OAuth2AuthenticationError: If the exchange fails.
- """
- try:
- response = requests.get(
- f"{AUTHN_BASE_URL}/token/exchange/{api_name}",
- headers={"Content-Type": "application/json"},
- params={
- "version": "latest",
- "try_use_cache": "false",
- "access_token": auth_token,
- },
- timeout=10,
- )
- response.raise_for_status()
-
- token_data = response.json()
- api_token = token_data.get("access_token") or token_data.get("token")
-
- if not api_token:
- raise OAuth2AuthenticationError(
- f"No access token in response for {api_name}"
- )
-
- return api_token
-
- except requests.exceptions.RequestException as e:
- raise OAuth2AuthenticationError(
- f"Failed to exchange token for {api_name} API: {e}"
- )
-
-
-if __name__ == "__main__":
- """Test the authentication flow."""
- try:
- tokens = authenticate()
- print("Tokens obtained successfully:")
- print(f" DM Token: {tokens['data_management_token'][:20]}...")
- print(f" SC Token: {tokens['site_capabilities_token'][:20]}...")
- except OAuth2AuthenticationError as e:
- print(f"Authentication failed: {e}")
- exit(1)
diff --git a/path_finder/path_finder.py b/path_finder/path_finder.py
deleted file mode 100644
index e5aaf12..0000000
--- a/path_finder/path_finder.py
+++ /dev/null
@@ -1,401 +0,0 @@
-#!/usr/bin/env python3
-#
-# path-finder: A tool for finding SKA data paths for mounting purposes.
-#
-
-import argparse
-import grp
-import itertools
-import os
-import re
-import subprocess
-from venv import logger
-
-import requests
-
-from models.data_management import DataLocationAPIResponse, DataLocation
-from oauth2_auth import authenticate, OAuth2AuthenticationError
-from models.site_capabilities import (
- Site,
- SitesAPIResponse,
- StorageAreaIDToNodeAndSite,
- NodesAPIResponse,
- get_all_node_storage_areas,
-)
-
-
-# Inputs - these can be inputs
-DATA_NAMESPACE = "daac"
-DATA_FILE = "pi24_test_run_1_cleaned.fits"
-SLURM_SITE_NAME = "UKSRC-CAM-PREPROD"
-
-# Upstream services
-DM_API_BASEURL = "https://data-management.srcnet.skao.int/api/v1"
-SC_API_BASEURL = "https://site-capabilities.srcnet.skao.int/api/v1"
-
-
-def main(
- namespace: str = DATA_NAMESPACE,
- file_name: str = DATA_FILE,
- site_name: str = SLURM_SITE_NAME,
- tokens: dict[str, str] = {},
- *args,
- **kwargs,
-) -> None:
- """Main function to locate data and print out storage area information."""
-
- check_namespace_available(namespace, tokens["data_management_token"])
- check_site_name_exists(site_name, tokens["site_capabilities_token"])
-
- site_storages = site_storage_areas(tokens["site_capabilities_token"])
- data_locations = locate_data(namespace, file_name, tokens["data_management_token"])
-
- print_data_locations_with_sites(site_storages, data_locations)
-
- if not is_data_located_at_site(site_name, data_locations, site_storages):
- print(
- f"Data file '{file_name}' in namespace '{namespace}' is not located at site '{site_name}'."
- )
- print("Ensure that the data is staged to the site before proceeding.")
- # TODO: If the data isn't available at the SLURM_SITE_NAME, perhaps we could stage it
- exit(1)
-
- rse_path = extract_rse_path(data_locations, namespace, file_name)
- print(f"RSE Path for file '{file_name}' in namespace '{namespace}': {rse_path}")
-
- mount_data(rse_path, namespace)
-
-
-def check_namespace_available(namespace: str, dm_api_token: str) -> None:
- """Check if the specified namespace is available.
-
- Args:
- namespace (str): The namespace to check.
-
- Raises:
- RuntimeError: If the namespace is not available.
- """
- all_namespaces = get_all_namespaces(dm_api_token)
- if namespace not in all_namespaces:
- raise RuntimeError(
- f"Namespace '{namespace}' not found in available namespaces: {all_namespaces}"
- )
-
-
-def get_all_namespaces(dm_api_token: str) -> list[str]:
- """Fetch all available namespaces from the Data Management API.
-
- Returns:
- A list of available namespace strings.
- """
- headers = {"Authorization": f"Bearer {dm_api_token}"}
- try:
- response = requests.get(f"{DM_API_BASEURL}/data/list", headers=headers)
- # TODO: Handle 401 Unauthorized
- response.raise_for_status()
- except requests.exceptions.RequestException as e:
- raise RuntimeError(f"Error requesting namespaces from DM API:\n{e}")
- namespaces = response.json()
- return namespaces
-
-
-def check_site_name_exists(site_name: str, sc_api_token: str) -> None:
- """Check if the specified site name exists.
-
- Args:
- site_name (str): The site name to check.
-
- Raises:
- RuntimeError: If the site name does not exist.
- """
- all_sites = all_site_names(sc_api_token)
- if site_name not in all_sites:
- logger.error(
- f"Error: Site name '{site_name}' not found in available sites:\n\n{', '.join(all_sites)}"
- )
- exit(1)
-
-
-def all_site_names(sc_api_token: str) -> list[str]:
- """Fetch the complete site capabilities and return all site name strings.
-
- Returns:
- A list of all available site name strings.
- """
- headers = {"Authorization": f"Bearer {sc_api_token}"}
- try:
- response = requests.get(f"{SC_API_BASEURL}/sites", headers=headers)
- response.raise_for_status()
- except requests.exceptions.RequestException as e:
- raise RuntimeError(f"Error requesting node information from SC API:\n{e}")
-
- nodes_response = SitesAPIResponse.validate_python(response.json())
-
- return [site.name for site in nodes_response]
-
-
-def site_storage_areas(sc_api_token: str) -> StorageAreaIDToNodeAndSite:
- """Fetch the site capabilities and obtain a storage area mapping of storage area IDs.
-
- Returns:
- StorageAreaIDToNodeAndSite: A mapping of storage area IDs to their corresponding node
- and site names.
- """
- headers = {"Authorization": f"Bearer {sc_api_token}"}
- try:
- response = requests.get(f"{SC_API_BASEURL}/nodes", headers=headers)
- response.raise_for_status()
- except requests.exceptions.RequestException as e:
- raise RuntimeError(f"Error requesting node information from SC API:\n{e}")
-
- nodes_response = NodesAPIResponse.validate_python(response.json())
-
- return get_all_node_storage_areas(nodes_response)
-
-
-def locate_data(
- namespace: str,
- file_name: str,
- dm_api_token: str,
-) -> list[DataLocation]:
- """Locate a data file within a specified namespace.
-
- Args:
- namespace (str): the file namespace - e.g. 'testing', 'daac', 'teal', 'neon'
- file_name (str): the path of the file within the namespace - e.g. 'pi24_test_run_1_cleaned.fits', 'pi25_daac_tests'
-
- Returns:
- A list of DataLocation objects representing the locations of the data file.
- """
-
- headers = {"Authorization": f"Bearer {dm_api_token}"}
-
- # Query the Data Management API to locate the file
- try:
- response = requests.get(
- f"{DM_API_BASEURL}/data/locate/{namespace}/{file_name}",
- headers=headers,
- )
- response.raise_for_status()
- except requests.exceptions.RequestException as e:
- raise RuntimeError(
- f"Error requesting location of file '{file_name}' in namespace '{namespace}' from DM API:\n{e}"
- )
-
- data_locations_response = DataLocationAPIResponse.validate_python(response.json())
- return data_locations_response
-
-
-def print_data_locations_with_sites(
- site_stores: StorageAreaIDToNodeAndSite, data_locations: list[DataLocation]
-) -> None:
- """Print data locations with their associated site information.
-
- Args:
- site_storages: Mapping of storage area IDs to node and site names.
- data_locations: List of data location objects to print.
- """
- for location in data_locations:
- node_site = site_stores.get(location.associated_storage_area_id)
- if node_site:
- node_name, site_name = node_site
- print(
- f"Data location ID: {location.identifier}, Storage Area ID: {location.associated_storage_area_id}, Node: {node_name}, Site: {site_name}"
- )
- else:
- print(
- f"Data location ID: {location.identifier}, Storage Area ID: {location.associated_storage_area_id}, Node/Site: Not found"
- )
-
-
-def is_data_located_at_site(
- site_name: str,
- data_locations: list[DataLocation],
- site_stores: StorageAreaIDToNodeAndSite,
-) -> bool:
- """Check if any data locations are associated with the specified site name.
-
- Args:
- site_name (str): The site name to check.
- data_locations (list[DataLocation]): The list of data locations to search.
-
- Returns:
- True if any data location is associated with the specified site name, False otherwise.
- """
- sites_with_data = [
- site_stores.get(location.associated_storage_area_id, (None, None))[1]
- for location in data_locations
- ]
-
- print(f"Sites with data: {sites_with_data}")
- if site_name in sites_with_data:
- return True
- return False
-
-
-def extract_rse_path(
- data_locations: list[DataLocation], namespace: str, file_name: str
-) -> str:
- """Extract the RSE path from data locations for a given namespace and file name.
-
- Do checks:
- - at least one path is found
- - consistency across paths from different replicas
-
- Args:
- data_locations (list[DataLocation]): The list of data locations to search.
- namespace (str): The namespace of the data.
- file_name (str): The name of the data file.
-
- Returns:
- The extracted RSE path.
- """
-
- rse_path_match = re.compile(rf"/{namespace}/.*$")
- matched_paths: set[str] = set()
- unmatched_paths: list[str] = []
-
- replica_uris = itertools.chain.from_iterable(
- [location.replicas for location in data_locations]
- )
- for uri in replica_uris:
- match = rse_path_match.search(uri)
- if match:
- matched_paths.add(match.group(0))
- else:
- unmatched_paths.append(uri)
-
- # Report any unmatched URIs
- if unmatched_paths:
- print(
- f"Warning: {len(unmatched_paths)} URIs did not match the expected pattern."
- )
- print(f"Unmatched URIs: {unmatched_paths}")
-
- # Validate we have exactly one unique path
- if not matched_paths:
- raise RuntimeError(
- f"No valid paths found for file '{file_name}' in namespace '{namespace}'."
- )
-
- if len(matched_paths) > 1:
- print(f"Warning: Multiple unique paths found: {matched_paths}")
- print(
- "We should check the path for the local RSE - by cross-referencing with site capabilities."
- )
- raise NotImplementedError("Handling multiple matched paths is not implemented.")
-
- return matched_paths.pop()
-
-
-def mount_data(rse_path: str, namespace: str) -> None:
- """Mount the data at the specified RSE path using sudo pathfinder.
-
- Args:
- rse_path (str): The RSE path to mount.
- namespace (str): The namespace of the data.
-
- Raises:
- RuntimeError: If the mount command fails.
- """
- print(f"Mounting data from RSE path: {rse_path} in namespace: {namespace}")
-
- # Construct the sudo command
- cmd = ["sudo", "pathfinder", "--mount", rse_path, namespace]
-
- try:
- # Execute the command
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- check=False, # Don't raise exception, handle manually
- timeout=30, # 30 second timeout
- )
-
- # Print stdout if available
- if result.stdout:
- print(f"Mount output: {result.stdout.strip()}")
-
- # Check return code
- if result.returncode != 0:
- error_msg = f"Mount command failed with exit code {result.returncode}"
- if result.stderr:
- error_msg += f": {result.stderr.strip()}"
- raise RuntimeError(error_msg)
-
- print(f"Successfully mounted {rse_path} in namespace {namespace}")
-
- except subprocess.TimeoutExpired:
- raise RuntimeError("Mount command timed out after 30 seconds")
- except FileNotFoundError:
- raise RuntimeError(
- "pathfinder command not found. Ensure it's installed and in PATH."
- )
- except PermissionError:
- raise RuntimeError("Permission denied. Ensure sudo is configured correctly.")
- except Exception as e:
- raise RuntimeError(f"Unexpected error during mount: {str(e)}")
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Path Finder")
- parser.add_argument("--namespace", required=True, help="Namespace of the data")
- parser.add_argument("--file_name", required=True, help="Name of the data file")
- parser.add_argument(
- "--site_name", required=True, help="Site name where data is staged"
- )
- parser.add_argument(
- "--no-login",
- action="store_true",
- help="Do not use OAuth2 for authentication - use environment variables instead",
- )
- args = parser.parse_args()
-
- # DEBUG: Print user and group information
- # user = os.getlogin()
- # groups = os.getgroups()
- # sudo_user = os.environ.get("SUDO_USER")
- # if sudo_user:
- # print(f"Running path-finder as sudo user: {sudo_user}")
- # else:
- # print("Not running Python as sudo.")
- # print(f"Running path-finder as local user: {user}")
- # group_names = [grp.getgrgid(gid).gr_name for gid in groups]
- # print(f"User '{user}' belongs to groups: {group_names}")
-
- if not args.no_login:
- # Use OAuth2 device code flow to authenticate
- try:
- print("Authenticating with OAuth2...")
- tokens = authenticate()
- print("Authentication successful!")
- except OAuth2AuthenticationError as e:
- print(f"Authentication failed: {e}")
- exit(1)
- else:
- # Fall back to environment variables
- try:
- data_management_access_token = os.environ["DATA_MANAGEMENT_ACCESS_TOKEN"]
- except KeyError:
- print(
- "Error: Please set DATA_MANAGEMENT_ACCESS_TOKEN environment variable or use --login flag."
- )
- exit(1)
-
- try:
- site_capabilities_access_token = os.environ[
- "SITE_CAPABILITIES_ACCESS_TOKEN"
- ]
- except KeyError:
- print(
- "Error: Please set SITE_CAPABILITIES_ACCESS_TOKEN environment variable or use --login flag."
- )
- exit(1)
- tokens = {
- "data_management_token": data_management_access_token,
- "site_capabilities_token": site_capabilities_access_token,
- }
-
-
- main(**vars(args), tokens=tokens)
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 3b08a87..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[project]
-name = "path-finder"
-version = "0.1.0"
-description = "CLI Program to authorise a users access to some srcNet data and return the RSE path"
-readme = "README.md"
-requires-python = ">=3.13"
-dependencies = [
- "pydantic>=2.12.5",
- "requests>=2.32.5",
-]
diff --git a/src/api_client.rs b/src/api_client.rs
new file mode 100644
index 0000000..7deaba9
--- /dev/null
+++ b/src/api_client.rs
@@ -0,0 +1,406 @@
+//! API client code for interacting with the SRCNet APIs
+
+use crate::models::*;
+use anyhow::{Context, Result};
+use reqwest::blocking::Client;
+
+const DM_API_BASEURL: &str = "https://data-management.srcnet.skao.int/api/v1";
+const SC_API_BASEURL: &str = "https://site-capabilities.srcnet.skao.int/api/v1";
+
+/// API client for interacting with the Path Finder APIs
+///
+/// This trait allows for abstraction and easier testing of API interactions.
+/// The `ApiClient` struct provides a concrete implementation.
+pub trait PathFinderApiClient {
+
+ /// Checks if the specified namespace is available by querying the DM API.
+ fn check_namespace_available(&self, namespace: &str) -> Result<()>;
+
+ /// Retrieves a list of all available namespaces from the DM API.
+ fn get_all_namespaces(&self) -> Result>;
+
+ /// Retrieves a mapping of storage area IDs to their associated node and site information from the SC API.
+ fn site_storage_areas(&self) -> Result;
+
+ /// Locates the specified data file within the given namespace by querying the DM API.
+ fn locate_data(&self, namespace: &str, file_name: &str) -> Result;
+}
+
+pub struct ApiClient {
+ client: Client,
+ dm_token: String,
+ sc_token: String,
+ dm_base_url: String,
+ sc_base_url: String,
+}
+
+impl ApiClient {
+ pub fn new(dm_token: String, sc_token: String) -> Self {
+ Self {
+ client: Client::new(),
+ dm_token,
+ sc_token,
+ dm_base_url: DM_API_BASEURL.to_string(),
+ sc_base_url: SC_API_BASEURL.to_string(),
+ }
+ }
+
+ #[cfg(test)]
+ pub fn new_with_urls(
+ dm_token: String,
+ sc_token: String,
+ dm_base_url: String,
+ sc_base_url: String,
+ ) -> Self {
+ Self {
+ client: Client::new(),
+ dm_token,
+ sc_token,
+ dm_base_url,
+ sc_base_url,
+ }
+ }
+}
+
+/// Implementation of the `PathFinderApiClient` trait for `ApiClient`, providing concrete logic for API interactions.
+///
+/// See the trait for method documentation.
+impl PathFinderApiClient for ApiClient {
+ fn check_namespace_available(&self, namespace: &str) -> Result<()> {
+ let namespaces = self.get_all_namespaces()?;
+ if !namespaces.contains(&namespace.to_string()) {
+ anyhow::bail!(
+ "Namespace '{}' not found in available namespaces: {:?}",
+ namespace,
+ namespaces
+ );
+ }
+ Ok(())
+ }
+
+ fn get_all_namespaces(&self) -> Result> {
+ let url = format!("{}/data/list", self.dm_base_url);
+ let response = self
+ .client
+ .get(&url)
+ .bearer_auth(&self.dm_token)
+ .send()
+ .context("Failed to request namespaces from DM API")?;
+
+ response
+ .error_for_status()
+ .context("DM API request failed")?
+ .json()
+ .context("Failed to parse namespaces response")
+ }
+
+ fn site_storage_areas(&self) -> Result {
+ let url = format!("{}/nodes", self.sc_base_url);
+ let response = self
+ .client
+ .get(&url)
+ .bearer_auth(&self.sc_token)
+ .send()
+ .context("Failed to request nodes from SC API")?;
+
+ let response = response
+ .error_for_status()
+ .context("SC API request failed")?;
+
+ let response_text = response.text().context("Failed to read response body")?;
+
+ let nodes: NodesAPIResponse = serde_json::from_str(&response_text).with_context(|| {
+ format!(
+ "Failed to parse nodes response. Response body:\n{}",
+ if response_text.len() > 1000 {
+ format!("{}... (truncated)", &response_text[..1000])
+ } else {
+ response_text.clone()
+ }
+ )
+ })?;
+
+ Ok(get_all_node_storage_areas(&nodes))
+ }
+
+ fn locate_data(&self, namespace: &str, file_name: &str) -> Result {
+ let url = format!(
+ "{}/data/locate/{}/{}",
+ self.dm_base_url, namespace, file_name
+ );
+ let response = self
+ .client
+ .get(&url)
+ .bearer_auth(&self.dm_token)
+ .send()
+ .with_context(|| {
+ format!(
+ "Failed to locate file '{}' in namespace '{}' from DM API",
+ file_name, namespace
+ )
+ })?;
+
+ let response = response
+ .error_for_status()
+ .context("DM API locate request failed")?;
+
+ let response_text = response.text().context("Failed to read response body")?;
+
+ serde_json::from_str(&response_text).with_context(|| {
+ format!(
+ "Failed to parse data locations response. Response body:\n{}",
+ if response_text.len() > 1000 {
+ format!("{}... (truncated)", &response_text[..1000])
+ } else {
+ response_text.clone()
+ }
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use httpmock::prelude::*;
+
+ fn client_for(dm_server: &MockServer, sc_server: &MockServer) -> ApiClient {
+ ApiClient::new_with_urls(
+ "dm-token".to_string(),
+ "sc-token".to_string(),
+ dm_server.base_url(),
+ sc_server.base_url(),
+ )
+ }
+
+ // --- get_all_namespaces ---
+
+ #[test]
+ fn get_all_namespaces_returns_parsed_list() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/list");
+ then.status(200).body(r#"["daac","lsst","ska-mid"]"#);
+ });
+
+ let namespaces = client_for(&dm, &sc).get_all_namespaces().unwrap();
+ assert_eq!(namespaces, vec!["daac", "lsst", "ska-mid"]);
+ }
+
+ #[test]
+ fn get_all_namespaces_propagates_401() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/list");
+ then.status(401).body("Unauthorized");
+ });
+
+ let err = client_for(&dm, &sc).get_all_namespaces().unwrap_err();
+ assert!(err.to_string().contains("DM API request failed"), "{err}");
+ }
+
+ #[test]
+ fn get_all_namespaces_propagates_500() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/list");
+ then.status(500).body("Internal Server Error");
+ });
+
+ assert!(client_for(&dm, &sc).get_all_namespaces().is_err());
+ }
+
+ // --- check_namespace_available ---
+
+ #[test]
+ fn check_namespace_available_succeeds_when_present() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/list");
+ then.status(200).body(r#"["daac","lsst"]"#);
+ });
+
+ assert!(client_for(&dm, &sc)
+ .check_namespace_available("daac")
+ .is_ok());
+ }
+
+ #[test]
+ fn check_namespace_available_bails_when_absent() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/list");
+ then.status(200).body(r#"["lsst"]"#);
+ });
+
+ let err = client_for(&dm, &sc)
+ .check_namespace_available("daac")
+ .unwrap_err();
+ assert!(err.to_string().contains("not found"), "{err}");
+ }
+
+ // --- site_storage_areas ---
+
+ #[test]
+ fn site_storage_areas_empty_nodes_returns_empty_map() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ sc.mock(|when, then| {
+ when.method(GET).path("/nodes");
+ then.status(200).body("[]");
+ });
+
+ let map = client_for(&dm, &sc).site_storage_areas().unwrap();
+ assert!(map.is_empty());
+ }
+
+ #[test]
+ fn site_storage_areas_parses_node_storage_mapping() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ sc.mock(|when, then| {
+ when.method(GET).path("/nodes");
+ then.status(200).body(
+ r#"[
+ {
+ "name": "uk-node",
+ "description": "UK Node",
+ "sites": [{
+ "id": "site-1",
+ "name": "Oxford",
+ "country": "GB",
+ "storages": [{
+ "id": "storage-1",
+ "name": "primary",
+ "areas": [{
+ "id": "area-abc",
+ "name": "fits-store",
+ "type": "disk",
+ "relative_path": "/data",
+ "tier": 1
+ }]
+ }]
+ }]
+ }
+ ]"#,
+ );
+ });
+
+ let map = client_for(&dm, &sc).site_storage_areas().unwrap();
+ assert!(map.contains_key("area-abc"));
+ let (node, site, area) = map.get("area-abc").unwrap();
+ assert_eq!(node, "uk-node");
+ assert_eq!(site, "Oxford");
+ assert_eq!(area, "fits-store");
+ }
+
+ #[test]
+ fn site_storage_areas_propagates_401() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ sc.mock(|when, then| {
+ when.method(GET).path("/nodes");
+ then.status(401);
+ });
+
+ let err = client_for(&dm, &sc).site_storage_areas().unwrap_err();
+ assert!(err.to_string().contains("SC API request failed"), "{err}");
+ }
+
+ #[test]
+ fn site_storage_areas_errors_on_malformed_json() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ sc.mock(|when, then| {
+ when.method(GET).path("/nodes");
+ then.status(200).body("not json at all");
+ });
+
+ let err = client_for(&dm, &sc).site_storage_areas().unwrap_err();
+ assert!(
+ err.to_string().contains("Failed to parse nodes response"),
+ "{err}"
+ );
+ }
+
+ // --- locate_data ---
+
+ #[test]
+ fn locate_data_returns_parsed_locations() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/locate/daac/file.fits");
+ then.status(200).body(
+ r#"[
+ {
+ "identifier": "loc-1",
+ "associated_storage_area_id": "area-abc",
+ "replicas": ["rucio://rse1/daac/2022/file.fits"],
+ "is_dataset": false
+ }
+ ]"#,
+ );
+ });
+
+ let locations = client_for(&dm, &sc)
+ .locate_data("daac", "file.fits")
+ .unwrap();
+ assert_eq!(locations.len(), 1);
+ assert_eq!(locations[0].identifier, "loc-1");
+ assert_eq!(locations[0].replicas[0], "rucio://rse1/daac/2022/file.fits");
+ }
+
+ #[test]
+ fn locate_data_returns_empty_list() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/locate/daac/missing.fits");
+ then.status(200).body("[]");
+ });
+
+ let locations = client_for(&dm, &sc)
+ .locate_data("daac", "missing.fits")
+ .unwrap();
+ assert!(locations.is_empty());
+ }
+
+ #[test]
+ fn locate_data_propagates_404() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/locate/daac/file.fits");
+ then.status(404);
+ });
+
+ assert!(client_for(&dm, &sc)
+ .locate_data("daac", "file.fits")
+ .is_err());
+ }
+
+ #[test]
+ fn locate_data_errors_on_malformed_json() {
+ let dm = MockServer::start();
+ let sc = MockServer::start();
+ dm.mock(|when, then| {
+ when.method(GET).path("/data/locate/daac/file.fits");
+ then.status(200).body("{bad json}");
+ });
+
+ let err = client_for(&dm, &sc)
+ .locate_data("daac", "file.fits")
+ .unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("Failed to parse data locations response"),
+ "{err}"
+ );
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..c62a756
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,245 @@
+//! CLI argument parsing and environment bootstrapping.
+//!
+//! This module owns everything that touches the command line and the process
+//! environment before any network calls are made:
+//!
+//! * [`Args`] — the `clap`-derived struct that models the accepted flags.
+//! * [`check_privileges`] — verifies the process is running as root via `sudo`
+//! and that `SUDO_USER` is set, bailing out with a user-friendly re-invocation
+//! hint otherwise.
+//! * [`get_tokens_from_env`] — reads pre-issued API tokens from environment
+//! variables, used when the caller wants to skip the OAuth2 device-code flow
+//! (`--no-login`).
+
+use anyhow::{Context, Result};
+use clap::Parser;
+use std::env;
+
+use crate::oauth2::Tokens;
+
+/// Command-line arguments for pathFinder.
+///
+/// Parse these with [`clap::Parser::parse`]; the resulting struct is then
+/// passed to [`check_privileges`] before any API work begins.
+#[derive(Parser, Debug)]
+#[command(name = "path-finder")]
+#[command(about = "A tool for finding SKA data paths for mounting purposes")]
+pub struct Args {
+ /// Namespace of the data (e.g. `"ska:ska-sdp/eb-m001-20240101-00000"`).
+ #[arg(long)]
+ pub namespace: String,
+
+ /// Name of the data file within the namespace.
+ #[arg(long)]
+ pub file_name: String,
+
+ /// Skip the OAuth2 device-code flow and read tokens from
+ /// `DATA_MANAGEMENT_ACCESS_TOKEN` and `SITE_CAPABILITIES_ACCESS_TOKEN`
+ /// instead.
+ #[arg(long)]
+ pub no_login: bool,
+
+ /// Unmount a previously mounted file instead of mounting it.
+ #[arg(long)]
+ pub unmount: bool,
+}
+
+/// Checks that the process is running as root via `sudo` and that `SUDO_USER`
+/// is set.
+///
+/// Both conditions are necessary: the mount/unmount OS calls require root, and
+/// `SUDO_USER` is used to build the bind-mount target path inside the invoking
+/// user's home directory. Running as the root user directly (without `sudo`)
+/// is rejected so that the home-directory expansion is always safe.
+///
+/// Prints an actionable re-invocation hint to stderr before bailing.
+pub fn check_privileges(args: &Args) -> Result<()> {
+ #[cfg(unix)]
+ {
+ let euid = unsafe { libc::geteuid() };
+ let sudo_user = env::var("SUDO_USER").ok();
+ check_privileges_impl(euid, sudo_user.as_deref(), args)?;
+ }
+
+ #[cfg(not(unix))]
+ {
+ anyhow::bail!("This tool is only supported on Unix systems");
+ }
+
+ Ok(())
+}
+
+/// Inner implementation of [`check_privileges`] with injectable `euid` and
+/// `sudo_user` values so the privilege logic can be unit-tested without
+/// running the test suite as root.
+///
+/// * `euid` — effective user-ID of the current process (`0` = root).
+/// * `sudo_user` — value of the `SUDO_USER` environment variable, if set.
+/// * `args` — parsed CLI flags, used to tailor the re-invocation hint.
+#[cfg(unix)]
+fn check_privileges_impl(euid: u32, sudo_user: Option<&str>, args: &Args) -> Result<()> {
+ if euid != 0 {
+ eprintln!("\nError: This tool requires root privileges for mount/unmount operations.");
+ eprintln!("Please re-run with sudo:");
+ if args.unmount {
+ eprintln!(
+ " sudo -E path-finder --namespace {} --file_name {} --unmount",
+ args.namespace, args.file_name
+ );
+ } else {
+ eprintln!(
+ " sudo -E path-finder --namespace {} --file_name {}",
+ args.namespace, args.file_name
+ );
+ }
+ anyhow::bail!("Insufficient privileges - sudo required");
+ }
+
+ if sudo_user.is_none() {
+ eprintln!("\nWarning: SUDO_USER not set. Are you running as root directly?");
+ eprintln!("Please use 'sudo' rather than running as root user.");
+ anyhow::bail!("SUDO_USER environment variable not set");
+ }
+
+ Ok(())
+}
+
+/// Reads API access tokens from the `DATA_MANAGEMENT_ACCESS_TOKEN` and
+/// `SITE_CAPABILITIES_ACCESS_TOKEN` environment variables.
+///
+/// This is the token source used with `--no-login`. Both variables must be
+/// present; a descriptive error is returned if either is absent so the user
+/// knows exactly which one to export.
+pub fn get_tokens_from_env() -> Result {
+ let dm_token = env::var("DATA_MANAGEMENT_ACCESS_TOKEN")
+ .context("Please set DATA_MANAGEMENT_ACCESS_TOKEN environment variable or omit --no-login to use OAuth2")?;
+
+ let sc_token = env::var("SITE_CAPABILITIES_ACCESS_TOKEN")
+ .context("Please set SITE_CAPABILITIES_ACCESS_TOKEN environment variable or omit --no-login to use OAuth2")?;
+
+ Ok(Tokens {
+ data_management_token: dm_token,
+ site_capabilities_token: sc_token,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex;
+
+ /// Serialise tests that mutate the process environment to avoid races when
+ /// the test binary runs suites in parallel.
+ static ENV_LOCK: Mutex<()> = Mutex::new(());
+
+ fn mount_args() -> Args {
+ Args {
+ namespace: "ska:ska-sdp/eb-m001-20240101-00000".into(),
+ file_name: "data.fits".into(),
+ no_login: false,
+ unmount: false,
+ }
+ }
+
+ fn unmount_args() -> Args {
+ Args {
+ namespace: "ska:ska-sdp/eb-m001-20240101-00000".into(),
+ file_name: "data.fits".into(),
+ no_login: false,
+ unmount: true,
+ }
+ }
+
+ // ── check_privileges_impl ────────────────────────────────────────────────
+
+ #[test]
+ #[cfg(unix)]
+ fn check_privileges_fails_when_not_root() {
+ let err = check_privileges_impl(1000, Some("alice"), &mount_args()).unwrap_err();
+ assert!(
+ err.to_string().contains("sudo required"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn check_privileges_fails_when_not_root_and_unmounting() {
+ // The bail message is identical; this path exercises the branch that
+ // includes `--unmount` in the eprintln! hint.
+ let err = check_privileges_impl(1000, Some("alice"), &unmount_args()).unwrap_err();
+ assert!(
+ err.to_string().contains("sudo required"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn check_privileges_fails_when_sudo_user_absent() {
+ let err = check_privileges_impl(0, None, &mount_args()).unwrap_err();
+ assert!(
+ err.to_string().contains("SUDO_USER"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn check_privileges_succeeds_when_root_with_sudo_user() {
+ check_privileges_impl(0, Some("alice"), &mount_args())
+ .expect("should succeed when euid == 0 and SUDO_USER is set");
+ }
+
+ // ── get_tokens_from_env ──────────────────────────────────────────────────
+
+ #[test]
+ fn get_tokens_from_env_returns_tokens_when_both_set() {
+ let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ env::set_var("DATA_MANAGEMENT_ACCESS_TOKEN", "dm-test-token");
+ env::set_var("SITE_CAPABILITIES_ACCESS_TOKEN", "sc-test-token");
+
+ let result = get_tokens_from_env();
+
+ env::remove_var("DATA_MANAGEMENT_ACCESS_TOKEN");
+ env::remove_var("SITE_CAPABILITIES_ACCESS_TOKEN");
+
+ let tokens = result.expect("should succeed when both vars are set");
+ assert_eq!(tokens.data_management_token, "dm-test-token");
+ assert_eq!(tokens.site_capabilities_token, "sc-test-token");
+ }
+
+ #[test]
+ fn get_tokens_from_env_errors_when_dm_token_absent() {
+ let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ env::remove_var("DATA_MANAGEMENT_ACCESS_TOKEN");
+ env::set_var("SITE_CAPABILITIES_ACCESS_TOKEN", "sc-test-token");
+
+ let result = get_tokens_from_env();
+
+ env::remove_var("SITE_CAPABILITIES_ACCESS_TOKEN");
+
+ let err = result.unwrap_err();
+ assert!(
+ err.to_string().contains("DATA_MANAGEMENT_ACCESS_TOKEN"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn get_tokens_from_env_errors_when_sc_token_absent() {
+ let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ env::set_var("DATA_MANAGEMENT_ACCESS_TOKEN", "dm-test-token");
+ env::remove_var("SITE_CAPABILITIES_ACCESS_TOKEN");
+
+ let result = get_tokens_from_env();
+
+ env::remove_var("DATA_MANAGEMENT_ACCESS_TOKEN");
+
+ let err = result.unwrap_err();
+ assert!(
+ err.to_string().contains("SITE_CAPABILITIES_ACCESS_TOKEN"),
+ "unexpected error: {err}"
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..459eefc
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,445 @@
+mod api_client;
+mod cli;
+mod models;
+mod mount;
+mod oauth2;
+mod path_finder;
+
+use anyhow::{Context, Result};
+use clap::Parser;
+use std::env;
+
+use api_client::{ApiClient, PathFinderApiClient};
+use cli::{check_privileges, get_tokens_from_env, Args};
+use models::{DataLocation, StorageAreaIDToNodeAndSite};
+use oauth2::{authenticate, Tokens};
+
+fn main() -> Result<()> {
+ let args = Args::parse();
+
+ check_privileges(&args)?;
+
+ // Handle unmount operation (no API calls needed)
+ if args.unmount {
+ let sudo_user = env::var("SUDO_USER").context("SUDO_USER not set")?;
+
+ let fits_path = format!("/{}/{}", args.namespace, args.file_name);
+ mount::unmount_operation(&fits_path, &args.namespace, &sudo_user)?;
+ return Ok(());
+ }
+
+ // Mount operation requires authentication and API calls
+ let tokens = if args.no_login {
+ get_tokens_from_env()?
+ } else {
+ println!("Authenticating with OAuth2...");
+ let tokens = authenticate(true)?;
+ println!("Authentication successful!");
+ tokens
+ };
+
+ run(&args.namespace, &args.file_name, &tokens)
+}
+
+/// Production wrapper: constructs an [`ApiClient`] from the supplied tokens and
+/// delegates to [`run_impl`] with the real path-finder helpers and [`do_exit`].
+fn run(namespace: &str, file_name: &str, tokens: &Tokens) -> Result<()> {
+ let client = ApiClient::new(
+ tokens.data_management_token.clone(),
+ tokens.site_capabilities_token.clone(),
+ );
+ run_impl(
+ namespace,
+ file_name,
+ &client,
+ path_finder::print_data_locations_with_sites,
+ path_finder::extract_rse_path,
+ path_finder::check_local_file_exists,
+ path_finder::mount_data,
+ do_exit,
+ )
+}
+
+/// Wraps [`std::process::exit`] so that [`run_impl`] can accept an injectable
+/// `Fn(i32)` rather than calling `process::exit` directly, keeping the
+/// orchestration logic unit-testable without spawning a subprocess.
+fn do_exit(code: i32) {
+ std::process::exit(code);
+}
+
+/// Core code for the mount workflow.
+///
+/// All external dependencies are injected so the function can be exercised in
+/// unit tests without live API endpoints, a real `/skadata` tree, or root
+/// privileges.
+///
+/// # Parameters
+/// * `namespace` — data namespace passed on the command line.
+/// * `file_name` — file name passed on the command line.
+/// * `client` — SRCNet API client; see [`PathFinderApiClient`].
+/// * `print_locations` — displays the replica list enriched with site names.
+/// Called once on the happy path and a second time when
+/// the file has not yet been staged locally.
+/// * `extract_path` — extracts the `//…` RSE path from replica URIs.
+/// * `file_exists` — returns `true` when the file is present under `/skadata`.
+/// * `mount` — performs the OS-level bind mount.
+/// * `exit_fn` — called with `1` when the file is not locally staged.
+/// In production this is [`do_exit`], which does not return.
+fn run_impl(
+ namespace: &str,
+ file_name: &str,
+ client: &dyn PathFinderApiClient,
+ print_locations: impl Fn(&StorageAreaIDToNodeAndSite, &[DataLocation]),
+ extract_path: impl Fn(&[DataLocation], &str, &str) -> Result,
+ file_exists: impl Fn(&str) -> bool,
+ mount: impl Fn(&str, &str) -> Result<()>,
+ exit_fn: impl Fn(i32),
+) -> Result<()> {
+ client.check_namespace_available(namespace)?;
+
+ let data_locations = client.locate_data(namespace, file_name)?;
+ let rse_path = extract_path(&data_locations, namespace, file_name)?;
+
+ println!(
+ "RSE Path for file '{}' in namespace '{}': {}",
+ file_name, namespace, rse_path
+ );
+
+ if !file_exists(&rse_path) {
+ println!("\n⚠️ File not found locally! ⚠️");
+ println!("Checking available storage areas at this site...");
+ let site_storages = client.site_storage_areas()?;
+ println!("\nThe file is available at the following locations:");
+ print_locations(&site_storages, &data_locations);
+ println!("\nPlease ensure the data has been staged to this local site before mounting.");
+ exit_fn(1);
+ return Ok(()); // unreachable in production (used for testing when exist_fn is mocked)
+ }
+
+ mount(&rse_path, namespace)?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use models::DataLocationAPIResponse;
+ use std::cell::{Cell, RefCell};
+ use std::collections::HashMap;
+
+ // ── constants ────────────────────────────────────────────────────────────
+
+ const NS: &str = "ska:ska-sdp/eb-m001-20240101-00000";
+ const FILE: &str = "data.fits";
+ const RSE_PATH: &str = "/ska:ska-sdp/eb-m001-20240101-00000/data.fits";
+ const OLYMPUSMONS_AREA_ID: &str = "2a73d212-8793-4011-a687-cad99841c269";
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ fn make_location() -> DataLocation {
+ DataLocation {
+ identifier: "MARSSRC-OLYMPUSMONS-T0".into(),
+ associated_storage_area_id: OLYMPUSMONS_AREA_ID.into(),
+ replicas: vec![format!(
+ "davs://xrootd01.olympusmons.marssrc.org:1094/skadata{RSE_PATH}"
+ )],
+ is_dataset: false,
+ }
+ }
+
+ fn make_site_storages() -> StorageAreaIDToNodeAndSite {
+ let mut m = HashMap::new();
+ m.insert(
+ OLYMPUSMONS_AREA_ID.to_string(),
+ (
+ "MARSSRC".to_string(),
+ "MARSSRC-OLYMPUSMONS".to_string(),
+ "MARSSRC_OLYMPUSMONS_XRD".to_string(),
+ ),
+ );
+ m
+ }
+
+ // ── MockApiClient ────────────────────────────────────────────────────────
+
+ struct MockApiClient {
+ namespace_ok: bool,
+ site_storages_ok: bool,
+ locate_data_ok: bool,
+ // call recording
+ check_namespace_called_with: RefCell