diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 46adaad..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true } diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index ec4e2af..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Build & Publish Release - -env: - NIX_CONFIG: experimental-features = nix-command flakes - -on: - workflow_dispatch: - inputs: - release_type: - description: "Release type: 'dev' or 'stable'" - required: true - default: "dev" - type: choice - options: - - dev - - stable - description: - description: "Release description" - required: true - type: string - -jobs: - build: - name: Build & Publish Release - runs-on: host - steps: - - name: Set up repository - uses: actions/checkout@v4 - - - name: Login to Docker Hub - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }} - run: | - set -eu - DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')" - DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')" - printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin" - - - name: Build & Push Docker image - run: | - nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest" - - - name: Build release binaries - run: | - set -eu - - nix build .#iota-daemon --print-build-logs - install -Dm755 result/bin/iota-daemon dist/iota-daemon - - nix build .#iota-ui --print-build-logs - install -Dm755 result/bin/iota-ui dist/iota-ui - - - name: Read release metadata - id: version - env: - RELEASE_TYPE: ${{ inputs.release_type }} - run: | - set -eu - - VERSION="$(nix eval --raw .#iota-daemon.version)" - SHORT_SHA="$(git rev-parse --short=7 HEAD)" - - case "$RELEASE_TYPE" in - dev) - TAG="${VERSION}-dev-${SHORT_SHA}" - PRERELEASE="true" - ;; - stable) - TAG="$VERSION" - PRERELEASE="false" - ;; - *) - echo "release_type must be either 'dev' or 'stable'" - exit 1 - ;; - esac - - echo "version=$VERSION" >> "$FORGEJO_OUTPUT" - echo "tag=$TAG" >> "$FORGEJO_OUTPUT" - echo "title=$TAG" >> "$FORGEJO_OUTPUT" - echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT" - - test -x "dist/iota-daemon" - test -x "dist/iota-ui" - - - name: Create release and upload binaries - env: - TOKEN: ${{ forgejo.token }} - API: ${{ forgejo.api_url }} - REPO: ${{ forgejo.repository }} - SHA: ${{ forgejo.sha }} - TAG: ${{ steps.version.outputs.tag }} - TITLE: ${{ steps.version.outputs.title }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} - DESCRIPTION: ${{ inputs.description }} - run: | - nix-shell -p curl jq --run ' - set -eu - - HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json \ - -H "Authorization: token $TOKEN" \ - "$API/repos/$REPO/releases/tags/$TAG") - - if [ "$HTTP_STATUS" = "200" ]; then - echo "Release $TAG already exists." - RELEASE_ID="$(jq -r .id release_out.json)" - else - echo "Creating release for $TAG" - RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg tag "$TAG" \ - --arg name "$TITLE" \ - --arg body "$DESCRIPTION" \ - --arg target "$SHA" \ - --argjson prerelease "$PRERELEASE" \ - '"'"'{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }'"'"')")" - RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" - fi - - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@dist/iota-daemon" - - curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@dist/iota-ui" - ' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4265c56 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: Build & Publish Docker Image + +on: + workflow_dispatch: + +jobs: + build: + name: Build & Publish Docker Image + runs-on: ubuntu-latest + steps: + - name: Set up repository + uses: actions/checkout@v4 + + - name: Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWD }} + + - name: Build & Push + run: | + docker build -t tensamin/iota:latest . + docker push tensamin/iota:latest diff --git a/.gitignore b/.gitignore index 9fb3aaa..0000aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,2 @@ target logs -agreements -languages/ -.envrc -.direnv -config.json -*.mk -*.sqlite* diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 3069632..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "mtp-type-maps"] - path = mtp-type-maps - url = ssh://git@git.methanium.net/tensamin/mtp-type-maps diff --git a/Cargo.lock b/Cargo.lock index e506091..cd56dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,36 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" +dependencies = [ + "actix-rt", + "bitflags 2.11.0", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + [[package]] name = "actix-codec" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -21,24 +44,24 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.3" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-tls", "actix-utils", - "base64 0.22.1", - "bitflags 2.13.1", + "base64", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.1.5", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -50,8 +73,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.2", - "sha1 0.11.0", + "rand 0.9.2", + "sha1", "smallvec", "tokio", "tokio-util", @@ -66,7 +89,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -86,9 +109,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.12.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c25da0441692de4ad67950cb7ed6c9ce4b669a6609525e547566c2e1ab4d695c" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" dependencies = [ "futures-core", "tokio", @@ -96,9 +119,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.8.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc8dcb6fa613d47c3b764a7dc672b4b31f25751dcbcf542f7b16f3e1f700044c" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" dependencies = [ "actix-rt", "actix-service", @@ -106,7 +129,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.10", "tokio", "tracing", ] @@ -131,7 +154,7 @@ dependencies = [ "actix-service", "actix-utils", "futures-core", - "impl-more 0.1.9", + "impl-more", "pin-project-lite", "rustls-pki-types", "tokio", @@ -152,9 +175,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.15.0" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" dependencies = [ "actix-codec", "actix-http", @@ -172,10 +195,10 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "futures-util", - "impl-more 0.3.5", + "impl-more", "itoa", "language-tags", "log", @@ -188,12 +211,30 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.6.3", "time", "tracing", "url", ] +[[package]] +name = "actix-web-actors" +version = "4.3.1+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" +dependencies = [ + "actix", + "actix-codec", + "actix-http", + "actix-web", + "bytes", + "bytestring", + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "actix-web-codegen" version = "4.3.0" @@ -203,7 +244,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -218,7 +259,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -230,14 +271,28 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", ] [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -250,9 +305,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] @@ -265,77 +320,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -346,32 +342,11 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - [[package]] name = "asn1-rs" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -379,7 +354,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] @@ -391,7 +366,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] @@ -403,18 +378,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] @@ -434,15 +409,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -451,15 +426,14 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] @@ -468,25 +442,13 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec 0.6.3", + "bit-vec", ] [[package]] @@ -495,15 +457,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -512,18 +465,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -534,20 +478,11 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "brotli" -version = "8.0.4" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -556,9 +491,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -566,21 +501,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.25.2" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -590,15 +519,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" dependencies = [ "bytes", ] @@ -623,9 +552,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -633,6 +562,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -641,50 +576,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -699,63 +599,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", - "zeroize", -] - -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "client" -version = "0.1.0" -dependencies = [ - "dashmap", - "iota-connection", - "iota-logger", - "iota-storage", - "iota-util", - "mtp", - "tokio", - "uuid", ] [[package]] @@ -767,18 +612,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -791,9 +624,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -803,18 +636,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "constant_time_eq" version = "0.3.1" @@ -876,15 +697,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc" version = "3.4.0" @@ -896,9 +708,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" @@ -910,16 +722,19 @@ dependencies = [ ] [[package]] -name = "critical-section" -version = "1.2.0" +name = "crossbeam-channel" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" @@ -927,7 +742,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", @@ -959,17 +774,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.1", -] - [[package]] name = "csscolorparser" version = "0.6.2" @@ -981,62 +785,19 @@ dependencies = [ ] [[package]] -name = "ctutils" -version = "0.4.2" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "cipher", ] [[package]] name = "darling" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -1044,33 +805,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.24.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "dashmap" -version = "6.2.1" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1082,9 +843,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deflate64" @@ -1098,27 +859,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -1138,6 +878,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] [[package]] name = "derive_arbitrary" @@ -1147,7 +890,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1169,7 +912,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn 2.0.117", "unicode-xid", ] @@ -1179,32 +922,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", -] - [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] @@ -1223,59 +954,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "ed25519" -version = "2.2.3" +name = "ed448-goldilocks" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8 0.11.0", - "signature 3.0.0", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", - "serde", - "sha2 0.10.9", + "fiat-crypto", + "hex", "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519 3.0.0", - "serde", - "sha2 0.11.0", - "signature 3.0.0", - "subtle", - "zeroize", ] [[package]] name = "either" -version = "1.18.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" @@ -1333,35 +1026,17 @@ dependencies = [ "regex", ] -[[package]] -name = "fastbloom" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" -dependencies = [ - "foldhash", - "libm", - "portable-atomic", - "siphasher", -] - [[package]] name = "fastrand" -version = "2.5.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" [[package]] name = "filedescriptor" @@ -1376,9 +1051,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "finl_unicode" @@ -1409,12 +1084,33 @@ 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 = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[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" @@ -1432,9 +1128,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1447,9 +1143,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1457,15 +1153,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1474,38 +1170,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1548,30 +1244,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", "wasm-bindgen", ] [[package]] -name = "glob" -version = "0.3.4" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" @@ -1594,16 +1301,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.18" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.5.0", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1611,68 +1318,21 @@ dependencies = [ "tracing", ] -[[package]] -name = "h3" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" -dependencies = [ - "bytes", - "fastrand", - "futures-util", - "http 1.5.0", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "h3-datagram" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" -dependencies = [ - "bytes", - "h3", - "pin-project-lite", -] - -[[package]] -name = "h3-quinn" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" -dependencies = [ - "bytes", - "futures", - "h3", - "h3-datagram", - "quinn", - "tokio", - "tokio-util", -] - -[[package]] -name = "h3-webtransport" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7" -dependencies = [ - "bytes", - "futures-util", - "h3", - "h3-datagram", - "http 1.5.0", - "pin-project-lite", - "tokio", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1681,27 +1341,40 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] name = "hashlink" -version = "0.12.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.16.1", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", ] [[package]] @@ -1718,11 +1391,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.13.0" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.13.0", + "hmac", ] [[package]] @@ -1731,16 +1404,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] @@ -1762,9 +1426,9 @@ dependencies = [ [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -1772,23 +1436,23 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.5.0", + "http 1.4.0", ] [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.5.0", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -1805,28 +1469,18 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "ctutils", - "typenum", -] - [[package]] name = "hyper" -version = "1.11.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.18", - "http 1.5.0", + "h2 0.4.13", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -1839,14 +1493,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.5.0", + "http 1.4.0", "hyper", "hyper-util", "rustls", + "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1858,18 +1513,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.5.0", + "http 1.4.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -1903,9 +1558,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -1917,9 +1572,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1930,9 +1585,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1944,17 +1599,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1965,15 +1619,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1984,6 +1638,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2003,9 +1663,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2017,20 +1677,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" -[[package]] -name = "impl-more" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" - [[package]] name = "indexmap" -version = "2.14.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2053,254 +1709,69 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "iota" version = "0.1.0" dependencies = [ - "clap", - "iota-cli", - "iota-core", - "iota-installer", - "iota-ipc", - "iota-paths", - "iota-process-manager", - "iota-terms", - "iota-util", - "serde_json", - "serde_yaml", - "tokio", -] - -[[package]] -name = "iota-auth" -version = "0.1.0" -dependencies = [ - "json", -] - -[[package]] -name = "iota-cli" -version = "0.1.0" -dependencies = [ + "actix-web", + "actix-web-actors", + "aes-gcm", + "async-trait", + "base64", "chrono", "crossterm", - "iota-ipc", - "iota-paths", - "iota-state", - "iota-terms", + "dashmap", + "futures", + "futures-util", + "hex", + "hkdf", + "hyper", + "hyper-util", + "json", + "lazy_static", "once_cell", "open", - "ratatui", - "serde", - "serde_yaml", - "tempfile", - "tokio", - "tokio-util", - "unicode-width", -] - -[[package]] -name = "iota-connection" -version = "0.1.0" -dependencies = [ - "iota-storage", - "iota-util", - "mtp", - "tokio", -] - -[[package]] -name = "iota-core" -version = "0.1.0" -dependencies = [ - "iota-cli", - "iota-logger", - "iota-paths", - "iota-state", - "iota-storage", - "iota-terms", - "iota-updater", - "iota-util", "pnet", - "tokio", - "web-server", -] - -[[package]] -name = "iota-daemon" -version = "0.1.0" -dependencies = [ - "iota-daemon-lib", - "iota-ipc", - "iota-logger", - "iota-paths", - "iota-storage", - "iota-terms", - "iota-util", - "omikron-connector", - "tokio", - "web-server", -] - -[[package]] -name = "iota-daemon-lib" -version = "0.1.0" -dependencies = [ - "async-trait", - "iota-ipc", - "iota-logger", - "iota-state", - "iota-storage", - "iota-updater", - "iota-util", - "libc", - "mtp", - "omikron-connector", - "serde_json", - "serde_yaml", - "sysinfo", - "tempfile", - "tokio", - "tokio-util", - "uuid", -] - -[[package]] -name = "iota-installer" -version = "0.1.0" -dependencies = [ - "anyhow", - "iota-paths", - "serde_json", - "tempfile", - "zip", -] - -[[package]] -name = "iota-ipc" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "iota-logger" -version = "0.1.0" -dependencies = [ - "iota-paths", - "iota-state", - "iota-util", - "json", - "mtp", - "once_cell", + "rand 0.8.5", + "rand_core 0.6.4", "ratatui", - "tokio", -] - -[[package]] -name = "iota-paths" -version = "0.1.0" - -[[package]] -name = "iota-process-manager" -version = "0.1.0" -dependencies = [ - "async-trait", - "libc", - "tempfile", - "tokio", -] - -[[package]] -name = "iota-state" -version = "0.1.0" -dependencies = [ - "dashmap", - "json", - "once_cell", - "sysinfo", - "tokio", -] - -[[package]] -name = "iota-storage" -version = "0.1.0" -dependencies = [ - "arc-swap", - "base64 0.22.1", - "iota-logger", - "iota-paths", - "iota-util", - "json", - "once_cell", - "r2d2", - "rand 0.8.7", + "reqwest", "rusqlite", - "serde", - "serde_yaml", - "thiserror 2.0.20", - "tokio", -] - -[[package]] -name = "iota-terms" -version = "0.1.0" -dependencies = [ - "iota-util", - "reqwest", - "tokio", -] - -[[package]] -name = "iota-updater" -version = "0.1.0" -dependencies = [ - "anyhow", - "ed25519-dalek 2.2.0", - "hex", - "iota-paths", - "serde", + "rustls", + "rustls-pemfile", "serde_json", - "sha2 0.11.0", - "tempfile", - "tokio", -] - -[[package]] -name = "iota-util" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "hex", - "iota-paths", - "mtp", - "reqwest", + "sha2", + "strum 0.27.2", + "strum_macros 0.27.2", "sysinfo", - "tempfile", "tokio", + "tokio-tungstenite", + "ttp-core", + "ttp-native", + "tungstenite", "uuid", "walkdir", + "warp", + "x448", "zip", ] [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "ipnetwork" @@ -2311,6 +1782,16 @@ dependencies = [ "serde", ] +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2330,12 +1811,6 @@ dependencies = [ "once_cell", ] -[[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.14.0" @@ -2353,32 +1828,27 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.22.4" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ + "cesu8", "cfg-if", "combine", - "jni-macros", - "jni-sys", + "jni-sys 0.3.1", "log", - "simd_cesu8", - "thiserror 2.0.20", + "thiserror 1.0.69", "walkdir", - "windows-link", + "windows-sys 0.45.0", ] [[package]] -name = "jni-macros" -version = "0.22.4" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", + "jni-sys 0.4.1", ] [[package]] @@ -2397,27 +1867,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -2435,26 +1906,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.20", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "keccak" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", + "thiserror 2.0.18", ] [[package]] @@ -2476,28 +1928,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libbz2-rs-sys" -version = "0.2.5" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "pkg-config", "vcpkg", @@ -2505,11 +1957,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.8" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", ] [[package]] @@ -2520,9 +1972,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -2558,17 +2010,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.18.2" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] @@ -2584,7 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ "crc", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -2599,9 +2051,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmem" @@ -2624,6 +2076,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2642,9 +2104,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -2653,216 +2115,20 @@ dependencies = [ ] [[package]] -name = "ml-dsa" -version = "0.1.1" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8 0.11.0", - "shake", - "signature 3.0.0", -] - -[[package]] -name = "mlkem-rs" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b0965b8b081668ff0398dc5e9dc3f2ebb9e833393f4ab5b9f725ddce11acef8" -dependencies = [ - "rand_core 0.6.4", - "serde", - "sha3", - "subtle", - "zeroize", -] - -[[package]] -name = "mlkem-tls" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b205d031298adf904d88efd6a57862d8650a4ab754aade19a9b5e87040bf4e" -dependencies = [ - "mlkem-rs", - "rand_core 0.6.4", - "subtle", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "module-lattice" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" -dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", -] - -[[package]] -name = "mtp" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "mtp-client", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-files", - "mtp-host", - "mtp-transport", - "mtp-type-map", - "mtp-webserver", -] - -[[package]] -name = "mtp-client" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-transport", - "rand 0.10.2", - "tokio", -] - -[[package]] -name = "mtp-codec" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "base64 0.23.1", - "byteorder", - "mtp-common", - "mtp-crypto", - "mtp-type-map", - "rand 0.10.2", - "thiserror 2.0.20", -] - -[[package]] -name = "mtp-common" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "quinn", - "thiserror 2.0.20", - "wtransport", -] - -[[package]] -name = "mtp-crypto" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "argon2", - "base64 0.22.1", - "chacha20poly1305", - "ed25519-dalek 3.0.0", - "getrandom 0.4.3", - "hkdf", - "ml-dsa", - "mlkem-tls", - "rand 0.10.2", - "rand_core 0.6.4", - "rustls", - "serde", - "sha2 0.11.0", - "thiserror 1.0.69", - "tokio", - "zeroize", -] - -[[package]] -name = "mtp-files" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "mtp-crypto", - "rand 0.10.2", - "thiserror 2.0.20", - "zeroize", -] - -[[package]] -name = "mtp-host" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-transport", - "rand 0.10.2", - "thiserror 2.0.20", - "tokio", - "tracing", - "wtransport", -] - -[[package]] -name = "mtp-transport" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "async-trait", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "rand 0.10.2", - "rcgen", - "rustls", - "rustls-native-certs", - "sha2 0.11.0", - "tokio", - "tracing", - "wtransport", - "zeroize", -] - -[[package]] -name = "mtp-type-map" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "serde", - "serde_yaml", -] - -[[package]] -name = "mtp-webserver" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#c30315af944bda05ecdb9c4e9cc350cb99cf7b2e" -dependencies = [ - "async-trait", - "bytes", - "h3", - "h3-quinn", - "h3-webtransport", - "http 1.5.0", - "http-body-util", - "hyper", - "hyper-util", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-host", - "mtp-transport", - "quinn", - "rustls", - "thiserror 2.0.20", - "tokio", - "tokio-rustls", - "tokio-stream", - "tracing", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] @@ -2871,7 +2137,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2905,9 +2171,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -2915,9 +2181,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -2927,14 +2193,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -2963,7 +2229,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", ] [[package]] @@ -2978,9 +2244,9 @@ dependencies = [ [[package]] name = "octets" -version = "0.3.6" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" +checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" [[package]] name = "oid-registry" @@ -2991,39 +2257,12 @@ dependencies = [ "asn1-rs", ] -[[package]] -name = "omikron-connector" -version = "0.1.0" -dependencies = [ - "async-trait", - "base64 0.22.1", - "dashmap", - "iota-connection", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "mtp", - "rand_core 0.6.4", - "reqwest", - "tokio", - "tokio-util", - "uuid", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "opaque-debug" version = "0.3.1" @@ -3032,12 +2271,39 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.4.1" +version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "is-wsl", "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "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]] @@ -3046,6 +2312,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -3055,43 +2333,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "other-iota" -version = "0.1.0" - -[[package]] -name = "palette" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" -dependencies = [ - "approx", - "libm", - "palette_derive", - "palette_math", -] - -[[package]] -name = "palette_derive" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "palette_math" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" -dependencies = [ - "libm", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3116,15 +2357,10 @@ dependencies = [ ] [[package]] -name = "password-hash" -version = "0.5.0" +name = "pathdiff" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" @@ -3132,8 +2368,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "digest", + "hmac", ] [[package]] @@ -3142,19 +2378,10 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3163,9 +2390,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -3173,9 +2400,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -3183,24 +2410,25 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", + "sha2", ] [[package]] @@ -3230,7 +2458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.7", + "rand 0.8.5", ] [[package]] @@ -3243,7 +2471,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -3255,37 +2483,37 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.1", - "spki 0.8.0", -] - [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "pnet" @@ -3332,7 +2560,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -3379,27 +2607,28 @@ dependencies = [ ] [[package]] -name = "poly1305" -version = "0.8.0" +name = "polyval" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "cpufeatures 0.2.17", + "cfg-if", + "cpufeatures", "opaque-debug", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3426,30 +2655,39 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +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 = "quinn" -version = "0.11.11" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", - "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2", - "thiserror 2.0.20", + "socket2 0.6.3", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3457,24 +2695,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", - "fastbloom", - "getrandom 0.4.3", + "getrandom 0.3.4", "lru-slab", - "rand 0.10.2", - "rand_pcg", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "slab", - "thiserror 2.0.20", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3482,23 +2717,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3515,37 +2750,25 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - [[package]] name = "rand" -version = "0.8.7" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.10.2" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3558,6 +2781,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + [[package]] name = "rand_core" version = "0.6.4" @@ -3569,52 +2808,42 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.1" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "rand_core 0.10.1", + "getrandom 0.3.4", ] [[package]] name = "ratatui" -version = "0.30.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", - "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", - "serde", ] [[package]] name = "ratatui-core" -version = "0.1.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "compact_str", - "critical-section", - "hashbrown 0.17.1", + "hashbrown 0.16.1", + "indoc", "itertools", "kasuari", "lru", - "palette", - "serde", - "strum", - "thiserror 2.0.20", + "strum 0.27.2", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3622,9 +2851,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" dependencies = [ "cfg-if", "crossterm", @@ -3634,30 +2863,19 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termina" +name = "ratatui-termwiz" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" -dependencies = [ - "instability", - "ratatui-core", - "termina", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" dependencies = [ "ratatui-core", "termwiz", @@ -3665,19 +2883,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", + "bitflags 2.11.0", + "hashbrown 0.16.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", - "serde", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width", @@ -3685,16 +2902,14 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ "aws-lc-rs", - "pem", - "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.18.1", "yasna", ] @@ -3704,14 +2919,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", ] [[package]] name = "regex" -version = "1.13.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3721,9 +2936,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3738,22 +2953,22 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", - "h2 0.4.18", - "http 1.5.0", + "h2 0.4.13", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -3796,21 +3011,21 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.18", ] [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3821,9 +3036,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3849,7 +3064,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3858,9 +3073,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -3874,9 +3089,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3895,9 +3110,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3905,9 +3120,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3932,9 +3147,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -3944,9 +3159,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -3973,13 +3188,10 @@ dependencies = [ ] [[package]] -name = "scheduled-thread-pool" -version = "0.2.7" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scopeguard" @@ -3993,7 +3205,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4012,15 +3224,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.28" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -4028,29 +3240,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -4071,39 +3283,15 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4113,47 +3301,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak 0.1.6", -] - -[[package]] -name = "shake" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest 0.11.3", - "keccak 0.2.2", - "sponge-cursor", + "cpufeatures", + "digest", ] [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" @@ -4186,52 +3342,17 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" @@ -4241,51 +3362,35 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.5" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der 0.8.1", -] - -[[package]] -name = "sponge-cursor" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" - [[package]] name = "sqlite-wasm-rs" -version = "0.5.5" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" dependencies = [ "cc", "js-sys", @@ -4311,13 +3416,31 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "strum_macros", + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4329,7 +3452,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4351,20 +3474,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4388,7 +3500,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4411,7 +3523,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4433,25 +3545,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "termina" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" -dependencies = [ - "bitflags 2.13.1", - "parking_lot", - "rustix", - "signal-hook", - "windows-sys 0.61.2", -] - [[package]] name = "terminfo" version = "0.9.0" @@ -4480,8 +3579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64 0.22.1", - "bitflags 2.13.1", + "base64", + "bitflags 2.11.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4498,7 +3597,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2 0.10.9", + "sha2", "signal-hook", "siphasher", "terminfo", @@ -4526,11 +3625,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.18", ] [[package]] @@ -4541,27 +3640,28 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.55" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", @@ -4573,15 +3673,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4589,9 +3689,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4599,9 +3699,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4614,9 +3714,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -4624,20 +3724,30 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "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]] @@ -4651,27 +3761,28 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.19" +name = "tokio-tungstenite" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ - "futures-core", - "pin-project-lite", + "futures-util", + "log", + "native-tls", "tokio", + "tokio-native-tls", + "tungstenite", ] [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", - "futures-util", - "libc", "pin-project-lite", "tokio", ] @@ -4693,20 +3804,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.11.0", "bytes", "futures-util", - "http 1.5.0", + "http 1.4.0", "http-body", + "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -4741,7 +3852,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4760,10 +3871,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "typenum" -version = "1.20.1" +name = "ttp-core" +version = "0.1.0" +source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" +dependencies = [ + "base64", + "byteorder", + "rand 0.8.5", + "strum 0.28.0", + "strum_macros 0.28.0", +] + +[[package]] +name = "ttp-native" +version = "0.1.0" +source = "git+https://github.com/Tensamin/TTP.git#e246d1af6a42c71514d0ccc06fef94d71e4ad167" +dependencies = [ + "quinn", + "rustls", + "rustls-native-certs", + "thiserror 2.0.18", + "tokio", + "ttp-core", + "wtransport", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -4771,6 +3925,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4779,9 +3939,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -4812,16 +3972,10 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.7.1" @@ -4860,12 +4014,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -4910,6 +4064,33 @@ dependencies = [ "try-lock", ] +[[package]] +name = "warp" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d06d9202adc1f15d709c4f4a2069be5428aa912cc025d6f268ac441ab066b0" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http 1.4.0", + "http-body", + "http-body-util", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", + "tower-service", + "tracing", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4918,18 +4099,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +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.127" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -4940,9 +4130,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -4950,9 +4140,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4960,43 +4150,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] [[package]] -name = "web-server" -version = "0.1.0" +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "bytes", - "http 1.5.0", - "iota-logger", - "mtp", - "tokio", - "tokio-util", + "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 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -5012,29 +4224,11 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-ui" -version = "0.1.0" -dependencies = [ - "actix-web", - "iota-cli", - "iota-ipc", - "iota-logger", - "iota-paths", - "iota-state", - "iota-storage", - "iota-util", - "rustls", - "rustls-pemfile", - "serde_json", - "tokio", -] - [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -5057,7 +4251,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "uuid", ] @@ -5195,7 +4389,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5206,7 +4400,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5254,13 +4448,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "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]] @@ -5272,20 +4484,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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]] @@ -5297,48 +4541,132 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5346,22 +4674,110 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +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 2.11.0", + "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.4" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wtransport" -version = "0.7.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" +checksum = "e56b611f195638f3790e4e5a41e9d777643a6c324ae4ffd1f0f53f2738e7678c" dependencies = [ "bytes", "pem", @@ -5370,39 +4786,55 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pki-types", - "sha2 0.11.0", - "socket2", - "thiserror 2.0.20", + "sha2", + "socket2 0.5.10", + "thiserror 2.0.18", "time", "tokio", "tracing", "url", "wtransport-proto", - "x509-parser", + "x509-parser 0.17.0", ] [[package]] name = "wtransport-proto" -version = "0.7.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" +checksum = "1627c5b59450278e9771aab35275d72bfa2788128c197c5af1e4a820c8737ef4" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.20", + "thiserror 2.0.18", "url", ] [[package]] -name = "x25519-dalek" -version = "2.0.1" +name = "x448" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" dependencies = [ - "curve25519-dalek 4.1.3", - "rand_core 0.6.4", - "serde", - "zeroize", + "ed448-goldilocks", + "hex", + "rand_core 0.5.1", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", ] [[package]] @@ -5418,27 +4850,25 @@ dependencies = [ "lazy_static", "nom", "oid-registry", - "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] [[package]] name = "yasna" -version = "0.6.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ - "bit-vec 0.9.1", "time", ] [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5453,35 +4883,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] @@ -5494,35 +4924,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.5.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -5531,9 +4961,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5542,13 +4972,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 2.0.117", ] [[package]] @@ -5565,13 +4995,13 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.3.4", - "hmac 0.12.1", + "hmac", "indexmap", "lzma-rust2", "memchr", "pbkdf2", "ppmd-rust", - "sha1 0.10.7", + "sha1", "time", "zeroize", "zopfli", @@ -5580,15 +5010,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index b56f6cb..8767b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,56 @@ -[workspace] -members = [ - "iota-storage", - "iota-connection", +[package] +name = "iota" +version = "0.1.0" +edition = "2024" + +[dependencies] +ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" } +ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" } + +actix-web = { version = "4", features = ["rustls-0_23"] } +actix-web-actors = "4" +aes-gcm = "0.10.3" +base64 = "0.22.1" +crossterm = "*" +futures = "*" +futures-util = "*" +hex = "*" +hkdf = "0.12.4" +hyper-util = { version = "*" } +hyper = { version = "1.8.1", features = [ + "capi", "client", - "iota-auth", - "other-iota", - "iota-updater", - "iota-terms", - "iota-state", - "iota-cli", - "iota", - "iota-daemon", - "iota-daemon-lib", - "iota-ipc", - "omikron-connector", - "web-server", - "web-ui", - "iota-logger", - "iota-util", - "iota-process-manager", - "iota-paths", - "iota-installer", - "iota-core", -] -resolver = "3" + "full", + "http1", + "http2", + "nightly", + "server", +] } +json = "*" +once_cell = "1.21.3" +rand = "0.8" +rand_core = { version = "0.6", features = ["getrandom", "std"] } +reqwest = "0.13.2" +rustls = { version = "0.23.37", features = ["aws-lc-rs"] } +sha2 = "0.10.9" +sysinfo = "0.38.3" +tokio = { version = "1.50.0", features = ["full"] } +tokio-tungstenite = { version = "*", features = ["native-tls"] } +tungstenite = "*" +uuid = { version = "*", features = ["v4"] } +walkdir = "2.5.0" +warp = "*" +x448 = { version = "*" } +rustls-pemfile = "2.2.0" +async-trait = "0.1.89" +zip = "6.0.0" +pnet = "0.35.0" +dashmap = "6.1.0" +strum = "0.27.2" +strum_macros = "0.27.2" +ratatui = "0.30.0" +open = "5.3.3" +chrono = "0.4.43" +serde_json = "1.0.149" +rusqlite = "0.39.0" +lazy_static = "1.5.0" diff --git a/LICENSE b/LICENSE index e952c84..d218eff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,16 @@ -Copyright (c) 2025 Methanium - +Copyright (c) [2025] [Methanium] All rights reserved. -No part of this software, source code, documentation, or -associated materials may be copied, reproduced, modified, -distributed, published, sublicensed, sold, or used to create -derivative works without prior written permission from the -copyright holder. +This software is protected by copyright. Copying, editing, +distributing, publicly performing, or any other use of this software +or its components, in source or binary form, is strictly prohibited without the express +written permission of the copyright holder. -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY -OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM -EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL -NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER -LIABILITY ARISING FROM THE SOFTWARE OR ITS USE. +FUTURE LICENSE ACCEPTANCE: +It is the copyright holder's intention to release this software in the future +under a license yet to be defined, which will, among other things, +allow private, non-commercial use. This statement does not constitute +a current license grant and does not alter the above +prohibition on use, copying, or modification. Until the formal +publication of such a future license, all rights remain +reserved. diff --git a/README.md b/README.md index 5a8c16a..f5f05a7 100644 --- a/README.md +++ b/README.md @@ -4,65 +4,3 @@ A lightweight, Rust-based TUI and service orchestrator for Tensamin IOTA. Iota manages users and stores their messages and communities. It can be run in a centralised, decentralised or hybrid mode. The Iota is a work in progress. - -## Terminal themes - -The TUI defaults to the ANSI theme. Select a theme for one invocation with `--theme`: - -```text -iota --theme monospace -iota --theme binary status -``` - -The available names are `monospace`, `binary`, `ansi`, and `surface`. Theme selection uses this precedence: `--theme`, `IOTA_THEME`, then `ui.yaml` in Iota's configuration directory. For example: - -```text -IOTA_THEME=surface iota -``` - -On Linux, the configuration file defaults to `~/.config/iota/ui.yaml` (or `$XDG_CONFIG_HOME/iota/ui.yaml` when set): - -```yaml -theme: surface -``` - -An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start. - -## Accepting terms without the TUI - -Iota services do not start until the required agreements have been accepted for -the deployment. Use the terminal flow to read each current document and type -the document-specific acceptance phrase: - -```text -iota terms accept -``` - -For a system-managed daemon, accept its deployment-scoped terms as an account -that can write the system Iota state directory (normally via `sudo`): - -```text -sudo iota terms accept --system -``` - -`iota terms status` reports the stored state, and `iota terms show eula`, -`iota terms show tos`, or `iota terms show privacy` displays an individual -document without accepting it. - -# Linux daemon installation - -The system-managed daemon runs as the dedicated `iota` account and listens on -`/run/iota/iota.sock` through socket activation. The system IPC socket is the -privilege boundary. Operator access is granted through the `iota-operators` -group, and every account admitted through that socket is authorized for the -full operator-console role, including user management, identity rotation, -configuration, and daemon lifecycle commands. After installing, add an -account with: - -```text -usermod -aG iota-operators USER -``` - -The user must start a new login session before supplementary group membership -is visible. Unix per-user deployments must set `IOTA_SOCKET` to an absolute -path; Iota does not derive its IPC socket from `XDG_RUNTIME_DIR`. diff --git a/client/Cargo.toml b/client/Cargo.toml deleted file mode 100644 index 8759d48..0000000 --- a/client/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "client" -version = "0.1.0" -edition = "2024" - -[dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client", "crypto"] } -iota-connection = { path = "../iota-connection" } -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-storage = { path = "../iota-storage" } -dashmap = "6.1.0" -tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "*", features = ["v4"] } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs deleted file mode 100644 index a43c308..0000000 --- a/client/src/client_connection.rs +++ /dev/null @@ -1,497 +0,0 @@ -use dashmap::DashMap; -use iota_connection::message_common::*; -use iota_connection::message_handlers; -use iota_connection::relay::message_security_class; -use iota_logger::{log_cv_in, log_cv_out, log_t}; -use iota_storage::util::config_util::CONFIG; -use iota_util::crypto_helper::keyring_from_base64; -use iota_util::crypto_util::{self}; -use mtp::client::{Receiver, Sender}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::Keyring; - -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::{Mutex, RwLock, mpsc, watch}; -use tokio::task::JoinHandle; -use uuid::Uuid; - -// ============================================================================ -// Waiting Task System -// ============================================================================ - -#[allow(dead_code)] -pub struct ClientConnection { - sender: Arc>>>, - receiver: Receiver, - connection_loop_handle: Arc>>>, - pub connection_id: Uuid, - shutdown_tx: Arc>>>, - pub waiting_tasks: - DashMap, CommunicationValue) -> bool + Send + Sync>>, - shutdown: Arc>, - keyring: Arc>>>, -} - -impl ClientConnection { - pub fn new( - sender: Arc>>>, - receiver: Receiver, - connection_loop_handle: Arc>>>, - connection_id: Uuid, - shutdown_tx: Arc>>>, - waiting_tasks: DashMap< - u32, - Box, CommunicationValue) -> bool + Send + Sync>, - >, - shutdown: Arc>, - ) -> Self { - Self { - sender, - receiver, - connection_loop_handle, - connection_id, - shutdown_tx, - waiting_tasks, - shutdown, - keyring: Arc::new(RwLock::new(None)), - } - } - - pub async fn set_keyring(&self, keyring: Arc) { - *self.keyring.write().await = Some(keyring); - } - - async fn local_keyring(&self) -> Result, String> { - if let Some(keyring) = self.keyring.read().await.as_ref().cloned() { - return Ok(keyring); - } - - let keyring_data = CONFIG - .load() - .keyring - .clone() - .ok_or_else(|| "Iota keyring is not configured".to_string())?; - let keyring = keyring_from_base64(&keyring_data) - .ok_or_else(|| "Iota keyring is invalid".to_string())?; - let keyring = Arc::new(keyring); - *self.keyring.write().await = Some(keyring.clone()); - Ok(keyring) - } - - pub fn start(self: Arc) { - let self_clone = self.clone(); - tokio::spawn(async move { - while let Ok(cv) = self_clone.receiver.receive().await { - if *self_clone.shutdown.read().await { - return; - } - - self.clone().handle_message(cv).await; - - if !self_clone.receiver.is_open() { - break; - } - } - // Handle Close - }); - } - - pub async fn stop(&self) { - if let Some(tx) = self.shutdown_tx.lock().await.take() { - let _ = tx.send(true); - } - - if let Some(handle) = self.connection_loop_handle.lock().await.take() { - handle.abort(); - } - - if let Some(sender) = self.sender.read().await.as_ref() { - sender.close().await; - } - - *self.sender.write().await = None; - } - - // ------------------------------------------------------------------------- - // Message Handling - // ------------------------------------------------------------------------- - pub async fn handle_message(self: Arc, cv: CommunicationValue) { - log_cv_in!(&cv); - - if cv.is_type(CommunicationType::Relay) { - log_t!( - "relay_from_client_rejected", - "legacy client path has no Relay router".to_string() - ); - let _ = self - .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - if matches!( - message_security_class(&cv), - iota_connection::relay::MessageSecurityClass::RelayOnly - ) { - let _ = self - .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - if cv.require_id().is_err() { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - if cv.is_type(CommunicationType::Challenge) { - self.handle_challenge(&cv).await; - return; - } - - if cv.is_type(CommunicationType::GetChatSecret) { - self.send_message(&message_handlers::handle_get_chat_secret(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::SaveAppData) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let _app_data = cv - .get_data(DataType::AppData) - .as_str() - .unwrap_or("") - .to_string(); - - let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_request_id(&cv) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::LoadAppData) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let app_data = String::new(); - - let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_request_id(&cv) - .with_receiver(sender_id) - .add_typed_default(DataType::AppData, DataValue::Str(app_data)); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::CreateApp) { - self.send_message(&message_handlers::handle_create_app(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::DeleteApp) { - self.send_message(&message_handlers::handle_delete_app(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::ClientConnected) { - self.send_message(&message_handlers::handle_client_connected(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::ClientStateAck) { - self.send_message(&message_handlers::handle_client_state_ack(&cv)) - .await; - return; - } - - // ************************************************ // - // Direct messages // - // ************************************************ // - - if cv.is_type(CommunicationType::MessageEdit) { - self.send_message(&message_handlers::handle_message_edit(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::MessageReactionAdd) { - self.send_message(&message_handlers::handle_message_reaction(&cv, true)) - .await; - return; - } - - if cv.is_type(CommunicationType::MessageReactionRemove) { - self.send_message(&message_handlers::handle_message_reaction(&cv, false)) - .await; - return; - } - - if cv.is_type(CommunicationType::MessageDeleteLive) { - self.send_message(&message_handlers::handle_message_delete(&cv)) - .await; - return; - } - - // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. - if cv.is_type(CommunicationType::MessageSend) { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - if cv.is_type(CommunicationType::MessagesGet) { - self.send_message(&message_handlers::handle_messages_get(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::MessageGet) { - self.send_message(&message_handlers::handle_message_get(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::GetChats) { - self.send_message(&message_handlers::handle_get_chats(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::AddCommunity) { - self.send_message(&message_handlers::handle_add_community(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::GetCommunities) { - self.send_message(&message_handlers::handle_get_communities(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::RemoveCommunity) { - self.send_message(&message_handlers::handle_remove_community(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::SettingsSave) { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - let _ = iota_storage::util::settings::save( - my_id_i64, - iota_storage::util::settings::GLOBAL_SESSION_ID, - settings_name, - settings_value, - ); - - let response = CommunicationValue::new(CommunicationType::SettingsSave) - .with_receiver(my_id) - .with_request_id(&cv); - - self.send_message(&response).await; - return; - } - - if cv.is_type(CommunicationType::SettingsLoad) { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_string() else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let settings_value_str = iota_storage::util::settings::load( - my_id_i64, - iota_storage::util::settings::GLOBAL_SESSION_ID, - &settings_name, - ) - .ok() - .flatten() - .unwrap_or_default(); - let response = CommunicationValue::new(CommunicationType::SettingsLoad) - .with_request_id(&cv) - .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) - .add_typed_default(DataType::SettingsName, DataValue::Str(settings_name)); - - self.send_message(&response).await; - return; - } - - if cv.is_type(CommunicationType::SettingsList) { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let settings = iota_storage::util::settings::list( - my_id_i64, - iota_storage::util::settings::GLOBAL_SESSION_ID, - ) - .unwrap_or_default(); - let settings_json = settings.into_iter().map(DataValue::Str).collect(); - let response = CommunicationValue::new(CommunicationType::SettingsList) - .with_request_id(&cv) - .with_receiver(my_id) - .add_typed_default(DataType::Settings, DataValue::Array(settings_json)); - - self.send_message(&response).await; - return; - } - } - - async fn handle_challenge(&self, cv: &CommunicationValue) { - let Ok(keyring) = self.local_keyring().await else { - return; - }; - - let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else { - return; - }; - - let solved = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok(); - - if let Some(solved) = solved { - let response = CommunicationValue::new(CommunicationType::ChallengeResponse) - .with_request_id(&cv) - .add_typed_default(DataType::Challenge, DataValue::Str(solved)); - - self.send_message(&response).await; - } - } - - // ------------------------------------------------------------------------- - // Public API - // ------------------------------------------------------------------------- - - pub async fn send_message(&self, cv: &CommunicationValue) { - if let Err(err) = self.send_message_result(cv).await { - log_t!("send_message_failed", err); - } - } - - async fn send_message_result(&self, cv: &CommunicationValue) -> Result<(), String> { - let sender_guard = self.sender.read().await; - if let Some(sender) = sender_guard.as_ref() { - if !sender.is_open() { - drop(sender_guard); - if let Some(sender) = self.sender.write().await.take() { - sender.close().await; - } - return Err("connection closed".to_string()); - } - - let sender_clone = Arc::clone(sender); - drop(sender_guard); - - log_cv_out!(&cv); - - if let Err(e) = sender_clone.send(cv).await { - return Err(e.to_string()); - } - - Ok(()) - } else { - Err("not connected".to_string()) - } - } - - pub async fn await_response( - self: Arc, - cv: &CommunicationValue, - timeout_duration: Option, - ) -> Result { - let (tx, mut rx) = mpsc::channel(1); - let msg_id = cv - .require_id() - .map_err(|error| format!("cannot await response without a message id: {error}"))?; - - let task_tx = tx.clone(); - self.waiting_tasks.insert( - msg_id, - Box::new(move |_, response_cv| { - let inner_tx = task_tx.clone(); - tokio::spawn(async move { - let _ = inner_tx.send(response_cv).await; - }); - true - }), - ); - - self.send_message(cv).await; - - let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); - - match tokio::time::timeout(timeout, rx.recv()).await { - Ok(Some(response_cv)) => Ok(response_cv), - Ok(_) => Err("Failed to receive response, channel was closed.".to_string()), - Err(_) => { - self.waiting_tasks.remove(&msg_id); - Err(format!( - "Request timed out after {} seconds.", - timeout.as_secs() - )) - } - } - } -} diff --git a/client/src/client_connection_manager.rs b/client/src/client_connection_manager.rs deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/lib.rs b/client/src/lib.rs deleted file mode 100644 index f3e6b6c..0000000 --- a/client/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod client_connection; -pub use client_connection::ClientConnection; diff --git a/communities/Cargo.lock b/communities/Cargo.lock deleted file mode 100644 index dec0ac6..0000000 --- a/communities/Cargo.lock +++ /dev/null @@ -1,5236 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-rt", - "bitflags 2.11.1", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] - -[[package]] -name = "actix-codec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-http" -version = "3.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" -dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-tls", - "actix-utils", - "base64", - "bitflags 2.11.1", - "brotli", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "flate2", - "foldhash 0.1.5", - "futures-core", - "h2 0.3.27", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.10.1", - "sha1 0.11.0", - "smallvec", - "tokio", - "tokio-util", - "tracing", - "zstd", -] - -[[package]] -name = "actix-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "actix-router" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex", - "regex-lite", - "serde", - "tracing", -] - -[[package]] -name = "actix-rt" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" -dependencies = [ - "futures-core", - "tokio", -] - -[[package]] -name = "actix-server" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2 0.5.10", - "tokio", - "tracing", -] - -[[package]] -name = "actix-service" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "actix-tls" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "impl-more", - "pin-project-lite", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tokio-util", - "tracing", -] - -[[package]] -name = "actix-utils" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] - -[[package]] -name = "actix-web" -version = "4.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-tls", - "actix-utils", - "actix-web-codegen", - "bytes", - "bytestring", - "cfg-if", - "cookie", - "derive_more", - "encoding_rs", - "foldhash 0.1.5", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2 0.6.3", - "time", - "tracing", - "url", -] - -[[package]] -name = "actix-web-actors" -version = "4.3.1+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98c5300b38fd004fe7d2a964f9a90813fdbe8a81fed500587e78b1b71c6f980" -dependencies = [ - "actix", - "actix-codec", - "actix-http", - "actix-web", - "bytes", - "bytestring", - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "actix-web-codegen" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" -dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[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 = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bytestring" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" -dependencies = [ - "bytes", -] - -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "communities" -version = "0.1.0" -dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf", - "hyper", - "hyper-util", - "iota-auth", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "lazy_static", - "once_cell", - "open", - "pnet", - "rand 0.8.6", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.10.9", - "strum 0.27.2", - "strum_macros 0.27.2", - "sysinfo", - "tokio", - "tokio-tungstenite", - "ttp-core", - "ttp-native", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] - -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[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 = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.0", - "const-oid", - "crypto-common 0.2.1", -] - -[[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 = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ed448-goldilocks" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" -dependencies = [ - "fiat-crypto", - "hex", - "subtle", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[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 = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fiat-crypto" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[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 = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[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 = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[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-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[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-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hashlink" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http 1.4.0", - "httpdate", - "mime", - "sha1 0.10.6", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http 1.4.0", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "httlib-huffman" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" - -[[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 = "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", - "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 = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2 0.4.14", - "http 1.4.0", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.4.0", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -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 = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "iota-auth" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "async-trait", - "base64", - "chrono", - "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf", - "hyper", - "hyper-util", - "iota-logger", - "iota-state", - "iota-storage", - "iota-util", - "json", - "lazy_static", - "once_cell", - "open", - "pnet", - "rand 0.8.6", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", - "serde_json", - "sha2 0.10.9", - "strum 0.27.2", - "strum_macros 0.27.2", - "sysinfo", - "tokio", - "tokio-tungstenite", - "ttp-core", - "ttp-native", - "tungstenite", - "uuid", - "walkdir", - "warp", - "x448", - "zip", -] - -[[package]] -name = "iota-logger" -version = "0.1.0" -dependencies = [ - "iota-state", - "iota-util", - "json", - "once_cell", - "ratatui", - "ttp-core", -] - -[[package]] -name = "iota-state" -version = "0.1.0" -dependencies = [ - "dashmap", - "json", - "once_cell", - "sysinfo", - "tokio", - "ttp-core", -] - -[[package]] -name = "iota-storage" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "base64", - "hex", - "hkdf", - "iota-logger", - "iota-state", - "iota-util", - "json", - "once_cell", - "rand 0.8.6", - "rand_core 0.6.4", - "ratatui", - "reqwest", - "rusqlite", - "sha2 0.10.9", - "sysinfo", - "tokio", - "ttp-core", - "ttp-native", - "uuid", - "walkdir", - "x448", - "zip", -] - -[[package]] -name = "iota-util" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "base64", - "hex", - "hkdf", - "rand_core 0.6.4", - "reqwest", - "sha2 0.10.9", - "sysinfo", - "tokio", - "ttp-core", - "ttp-native", - "uuid", - "walkdir", - "x448", - "zip", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "ipnetwork" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" -dependencies = [ - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" - -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "language-tags" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" - -[[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 = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libsqlite3-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.1", -] - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - -[[package]] -name = "local-waker" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" - -[[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" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rust2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" -dependencies = [ - "crc", - "sha2 0.10.9", -] - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "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 = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "no-std-net" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "octets" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open" -version = "5.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "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.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[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 = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac", -] - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "pnet" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "682396b533413cc2e009fbb48aadf93619a149d3e57defba19ff50ce0201bd0d" -dependencies = [ - "ipnetwork", - "pnet_base", - "pnet_datalink", - "pnet_packet", - "pnet_sys", - "pnet_transport", -] - -[[package]] -name = "pnet_base" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" -dependencies = [ - "no-std-net", -] - -[[package]] -name = "pnet_datalink" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79e70ec0be163102a332e1d2d5586d362ad76b01cec86f830241f2b6452a7b7" -dependencies = [ - "ipnetwork", - "libc", - "pnet_base", - "pnet_sys", - "winapi", -] - -[[package]] -name = "pnet_macros" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 2.0.117", -] - -[[package]] -name = "pnet_macros_support" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed67a952585d509dd0003049b1fc56b982ac665c8299b124b90ea2bdb3134ab" -dependencies = [ - "pnet_base", -] - -[[package]] -name = "pnet_packet" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c96ebadfab635fcc23036ba30a7d33a80c39e8461b8bd7dc7bb186acb96560f" -dependencies = [ - "glob", - "pnet_base", - "pnet_macros", - "pnet_macros_support", -] - -[[package]] -name = "pnet_sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d4643d3d4db6b08741050c2f3afa9a892c4244c085a72fcda93c9c2c9a00f4b" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "pnet_transport" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f604d98bc2a6591cf719b58d3203fd882bdd6bf1db696c4ac97978e9f4776bf" -dependencies = [ - "libc", - "pnet_base", - "pnet_packet", - "pnet_sys", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppmd-rust" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[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 = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -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 = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.1", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", - "kasuari", - "lru", - "strum 0.27.2", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum 0.27.2", - "time", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "rcgen" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" -dependencies = [ - "aws-lc-rs", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[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-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "h2 0.4.14", - "http 1.4.0", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "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 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" -dependencies = [ - "bitflags 2.11.1", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[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.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[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 2.11.1", - "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.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[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_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 = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[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 = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 = "sysinfo" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "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.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.1", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[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 = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.3", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -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-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "native-tls", - "tokio", - "tokio-native-tls", - "tungstenite", -] - -[[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.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-util", - "http 1.4.0", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[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 = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 = "ttp-core" -version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#7829ee8b296b06731bba3d1749f56767d58cf153" -dependencies = [ - "base64", - "byteorder", - "rand 0.8.6", - "serde_json", - "strum 0.28.0", - "strum_macros 0.28.0", -] - -[[package]] -name = "ttp-native" -version = "0.1.0" -source = "git+https://git.methanium.net/Tensamin/TTP.git#7829ee8b296b06731bba3d1749f56767d58cf153" -dependencies = [ - "quinn", - "rustls", - "rustls-native-certs", - "thiserror 2.0.18", - "tokio", - "ttp-core", - "wtransport", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "native-tls", - "rand 0.9.4", - "sha1 0.10.6", - "thiserror 2.0.18", -] - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[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 = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - -[[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 = "warp" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http 1.4.0", - "http-body", - "http-body-util", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project", - "scoped-tls", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tokio-util", - "tower-service", - "tracing", -] - -[[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.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[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 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -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 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[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" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[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.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.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-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[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.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "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" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[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 2.11.1", - "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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wtransport" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" -dependencies = [ - "bytes", - "pem", - "quinn", - "rcgen", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "sha2 0.11.0", - "socket2 0.6.3", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "url", - "wtransport-proto", - "x509-parser", -] - -[[package]] -name = "wtransport-proto" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" -dependencies = [ - "httlib-huffman", - "octets", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "x448" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" -dependencies = [ - "ed448-goldilocks", - "hex", - "rand_core 0.5.1", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "aws-lc-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec 0.9.1", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -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" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zip" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" -dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", - "crc32fast", - "deflate64", - "flate2", - "getrandom 0.3.4", - "hmac", - "indexmap", - "lzma-rust2", - "memchr", - "pbkdf2", - "ppmd-rust", - "sha1 0.10.6", - "time", - "zeroize", - "zopfli", - "zstd", -] - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/communities/Cargo.toml b/communities/Cargo.toml deleted file mode 100644 index 36ef42e..0000000 --- a/communities/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "communities" -version = "0.1.0" -edition = "2024" - -[dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -iota-util = { path = "../iota-util" } - -futures = "*" -rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom", "std"] } -sha2 = "0.11.0" -tokio = { version = "1.50.0", features = ["full"] } -uuid = { version = "*", features = ["v4"] } -x448 = { version = "*" } diff --git a/dockerfile b/dockerfile index fcbfafa..ee00add 100644 --- a/dockerfile +++ b/dockerfile @@ -4,7 +4,7 @@ FROM rust:latest AS builder WORKDIR /app COPY . . -RUN cargo build --release -p iota-daemon +RUN cargo build --release # Runtime stage FROM debian:sid @@ -13,10 +13,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/iota-daemon . - -RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota +COPY --from=builder /app/target/release/iota . EXPOSE 1984 -CMD ["./iota-daemon"] +CMD ["./iota"] \ No newline at end of file diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 3042264..0000000 --- a/flake.lock +++ /dev/null @@ -1,82 +0,0 @@ -{ - "nodes": { - "flake-parts": { - "inputs": { - "nixpkgs-lib": "nixpkgs-lib" - }, - "locked": { - "lastModified": 1782949081, - "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-lib": { - "locked": { - "lastModified": 1782614948, - "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", - "owner": "nix-community", - "repo": "nixpkgs.lib", - "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nixpkgs.lib", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1784526465, - "narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index e2ca8a1..0000000 --- a/flake.nix +++ /dev/null @@ -1,275 +0,0 @@ -{ - description = "Iota"; - - inputs = { - nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - flake-parts.url = "github:hercules-ci/flake-parts"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = inputs @ { - self, - nixpkgs, - flake-parts, - rust-overlay, - ... - }: - flake-parts.lib.mkFlake {inherit inputs;} { - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - - perSystem = { - self', - pkgs, - system, - ... - }: let - rustPkgs = import nixpkgs { - inherit system; - overlays = [(import rust-overlay)]; - }; - rustToolchain = rustPkgs.rust-bin.stable.latest.default.override { - extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"]; - }; - commonBuildInputs = with pkgs; [openssl sqlite]; - commonNativeBuildInputs = with pkgs; [cmake perl pkg-config]; - in { - packages = { - default = pkgs.rustPlatform.buildRustPackage { - pname = "iota"; - version = "0.1.0"; - src = ./.; - cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"]; - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; - nativeBuildInputs = commonNativeBuildInputs; - buildInputs = commonBuildInputs; - dontUseCmakeConfigure = true; - passthru.dataDir = "/var/lib/iota"; - }; - - iota-daemon = self'.packages.default.overrideAttrs (old: { - pname = "iota-daemon"; - cargoBuildFlags = ["-p" "iota-daemon"]; - postInstall = '' - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota-daemon" ]; then - rm "$f" - fi - done - ''; - }); - - iota-ui = self'.packages.default.overrideAttrs (old: { - pname = "iota-ui"; - cargoBuildFlags = ["-p" "iota"]; - postInstall = '' - for f in $out/bin/*; do - if [ "$(basename "$f")" != "iota" ]; then - rm "$f" - fi - done - if [ -f "$out/bin/iota" ]; then - mv "$out/bin/iota" "$out/bin/iota-ui" - fi - ''; - }); - }; - - devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config]; - buildInputs = commonBuildInputs; - }; - }; - - flake = { - nixosModules.default = { - config, - pkgs, - lib, - ... - }: let - cfg = config.services.iota; - defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); - - configFormat = pkgs.formats.yaml {}; - configFile = - if cfg.settingsFile != null - then cfg.settingsFile - else configFormat.generate "iota-config.yaml" cfg.settings; - - descriptionText = "Tensamin Iota daemon"; - in { - options.services.iota = { - enable = lib.mkEnableOption "Enable the Iota service."; - - stateDir = lib.mkOption { - type = lib.types.str; - default = "/var/lib/iota"; - description = "Persistent mutable Iota state."; - }; - cacheDir = lib.mkOption { type = lib.types.str; default = "/var/cache/iota"; }; - runtimeDir = lib.mkOption { type = lib.types.str; default = "/run/iota"; }; - logDir = lib.mkOption { type = lib.types.str; default = "/var/log/iota"; }; - assetDir = lib.mkOption { type = lib.types.str; default = "${cfg.package}/share/iota/web"; }; - - certFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to the SSL certificate file (cert.pem)."; - }; - - keyFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to the SSL private key file (cert.key)."; - }; - - environmentFiles = lib.mkOption { - type = lib.types.listOf lib.types.path; - default = []; - description = "Environment files to load for the Iota service."; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to open the firewall for ports used by Iota."; - }; - - bindAddress = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "IP address to bind the HTTP server to."; - }; - - package = lib.mkOption { - type = lib.types.package; - default = defaultPackage; - description = "The Iota package to use."; - }; - - settings = lib.mkOption { - type = lib.types.attrs; - default = {}; - description = "Configuration attributes for Iota, written to YAML."; - }; - - settingsFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to an existing YAML file to use instead of generating from settings."; - }; - }; - - config = lib.mkIf cfg.enable { - users.users.iota = { - isSystemUser = true; - group = "iota"; - home = cfg.stateDir; - createHome = true; - description = "Iota service user"; - shell = pkgs.bash; - }; - - users.groups.iota = {}; - - systemd.sockets.iota = { - description = "${descriptionText} IPC socket"; - wantedBy = ["sockets.target"]; - socketConfig = { - ListenStream = "/run/iota/iota.sock"; - SocketMode = "0660"; - SocketUser = "iota"; - SocketGroup = "iota"; - DirectoryMode = "0750"; - Backlog = 5; - RemoveOnStop = "true"; - NonBlocking = true; - }; - }; - - systemd.services.iota = { - description = descriptionText; - wantedBy = ["multi-user.target"]; - after = ["network.target" "iota.socket"]; - requires = ["iota.socket"]; - - serviceConfig = - { - Type = "simple"; - User = "iota"; - Group = "iota"; - ExecStart = "${cfg.package}/bin/iota-daemon"; - - Restart = "on-failure"; - RestartSec = "5s"; - RuntimeDirectory = "iota"; - RuntimeDirectoryMode = "0750"; - StateDirectory = "iota"; - StateDirectoryMode = "0750"; - CacheDirectory = "iota"; - CacheDirectoryMode = "0750"; - LogsDirectory = "iota"; - LogsDirectoryMode = "0750"; - - # Exit code 75 = restart requested - RestartPreventExitStatus = "0"; - RestartForceExitStatus = "75"; - - TimeoutStopSec = "10"; - KillMode = "mixed"; - KillSignal = "SIGTERM"; - - AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; - CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; - - ProtectSystem = "strict"; - ProtectHome = true; - PrivateTmp = true; - NoNewPrivileges = true; - ReadWritePaths = [cfg.stateDir cfg.cacheDir cfg.runtimeDir cfg.logDir]; - ReadOnlyPaths = [configFile cfg.assetDir]; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - LockPersonality = true; - MemoryDenyWriteExecute = true; - Environment = [ - "BIND_ADDRESS=${cfg.bindAddress}" - "IOTA_SOCKET=/run/iota/iota.sock" - "IOTA_CONFIG_FILE=${configFile}" - "IOTA_STATE_DIR=${cfg.stateDir}" - "IOTA_CACHE_DIR=${cfg.cacheDir}" - "IOTA_RUNTIME_DIR=${cfg.runtimeDir}" - "IOTA_LOG_DIR=${cfg.logDir}" - "IOTA_ASSET_DIR=${cfg.assetDir}" - "IOTA_DEPLOYMENT_MODE=system_socket_activated" - "IOTA_SUPERVISOR=systemd" - ]; - } - // lib.optionalAttrs (cfg.environmentFiles != []) { - EnvironmentFile = cfg.environmentFiles; - }; - }; - - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [1984]; - allowedUDPPorts = [1984]; - }; - }; - }; - }; - }; -} diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml deleted file mode 100644 index f35935a..0000000 --- a/iota-auth/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "iota-auth" -version = "0.1.0" -edition = "2024" - -[dependencies] -json = "*" diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs deleted file mode 100644 index 8b13789..0000000 --- a/iota-auth/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml deleted file mode 100644 index 87b3954..0000000 --- a/iota-cli/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "iota-cli" -version = "0.1.0" -edition = "2024" - -[features] -legacy-commands = [ -] - -[dependencies] -iota-state = { path = "../iota-state" } -iota-terms = { path = "../iota-terms" } -iota-ipc = { path = "../iota-ipc" } -iota-paths = { path = "../iota-paths" } - - -chrono = "0.4.43" -crossterm = "*" -once_cell = "1.21.3" -open = "5.3.3" -ratatui = "0.30.0" -serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" -tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } -unicode-width = "0.2" - -[dev-dependencies] -tempfile = "3" diff --git a/iota-cli/src/app_state.rs b/iota-cli/src/app_state.rs deleted file mode 100755 index cfb4b3b..0000000 --- a/iota-cli/src/app_state.rs +++ /dev/null @@ -1 +0,0 @@ -pub use iota_state::*; diff --git a/iota-cli/src/controls/action.rs b/iota-cli/src/controls/action.rs deleted file mode 100644 index 4fb3c27..0000000 --- a/iota-cli/src/controls/action.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ControlAction { - FocusNext, - FocusPrevious, - Select, - Activate, -} diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs deleted file mode 100644 index 1ce27e4..0000000 --- a/iota-cli/src/controls/button.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::theme::ResolvedTheme; -use ratatui::{ - Frame, - layout::{Alignment, Rect}, - text::Span, - widgets::Paragraph, -}; -use unicode_width::UnicodeWidthStr; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ButtonIntent { - Primary, - Neutral, - Cancel, - Destructive, -} -pub struct ActionButton<'a> { - pub label: &'a str, - pub intent: ButtonIntent, - pub focused: bool, - pub enabled: bool, -} -pub fn render_button( - frame: &mut Frame, - area: Rect, - button: ActionButton<'_>, - theme: &ResolvedTheme, -) { - let style = if !button.enabled { - theme.buttons.disabled - } else { - match (button.intent, button.focused) { - (ButtonIntent::Primary, true) => theme.buttons.primary_focused, - (ButtonIntent::Primary, false) => theme.buttons.primary, - (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, - (ButtonIntent::Neutral, false) => theme.buttons.neutral, - (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, - (ButtonIntent::Cancel, false) => theme.buttons.cancel, - (ButtonIntent::Destructive, _) => theme.buttons.destructive, - } - }; - frame.render_widget( - Paragraph::new(Span::styled( - if button.focused { - format!("› {}", button.label) - } else { - button.label.to_owned() - }, - style, - )) - .alignment(Alignment::Center), - area, - ); -} -pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option> { - let required = minimums - .iter() - .try_fold(0u16, |total, width| total.checked_add(*width))?; - if required > available { - return None; - } - if minimums.is_empty() { - return Some(Vec::new()); - } - let extra = available - required; - let count = minimums.len() as u16; - Some( - minimums - .iter() - .enumerate() - .map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count)) - .collect(), - ) -} -pub fn button_minimum_width(label: &str) -> u16 { - UnicodeWidthStr::width(label) - .saturating_add(2) - .min(u16::MAX as usize) as u16 -} diff --git a/iota-cli/src/controls/checkbox_group.rs b/iota-cli/src/controls/checkbox_group.rs deleted file mode 100644 index f985510..0000000 --- a/iota-cli/src/controls/checkbox_group.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; -use std::{collections::HashSet, hash::Hash}; - -pub struct CheckboxItem { - pub value: T, - pub label: String, - pub description: Option, - pub enabled: bool, - pub disabled_reason: Option, -} -pub struct CheckboxGroup { - items: Vec>, - selected: HashSet, - focused_index: usize, - focus_policy: DisabledFocusPolicy, - wrap_navigation: bool, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CheckboxGroupError { - Empty, - DuplicateValue, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CheckboxChange { - Selected(T), - Deselected(T), - IgnoredDisabled(T), - NoItem, -} -impl CheckboxGroup { - pub fn new( - items: Vec>, - selected: impl IntoIterator, - ) -> Result { - let mut values = HashSet::new(); - if items.iter().any(|item| !values.insert(item.value.clone())) { - return Err(CheckboxGroupError::DuplicateValue); - } - let selected = selected - .into_iter() - .filter(|value| values.contains(value)) - .collect(); - let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0); - Ok(Self { - items, - selected, - focused_index, - focus_policy: DisabledFocusPolicy::Skip, - wrap_navigation: true, - }) - } - pub fn items(&self) -> &[CheckboxItem] { - &self.items - } - pub fn selected(&self) -> &HashSet { - &self.selected - } - pub fn focused_item(&self) -> Option<&CheckboxItem> { - self.items.get(self.focused_index) - } - pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { - self.focus_policy = policy; - } - pub fn set_wrap_navigation(&mut self, wrap: bool) { - self.wrap_navigation = wrap; - } - pub fn focus_next(&mut self) { - self.move_focus(true); - } - pub fn focus_previous(&mut self) { - self.move_focus(false); - } - fn move_focus(&mut self, forward: bool) { - if self.items.is_empty() { - return; - } - for step in 1..=self.items.len() { - let current = self.focused_index as isize; - let delta = if forward { - step as isize - } else { - -(step as isize) - }; - let raw = current + delta; - let next = if self.wrap_navigation { - raw.rem_euclid(self.items.len() as isize) as usize - } else if raw < 0 || raw >= self.items.len() as isize { - return; - } else { - raw as usize - }; - if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { - self.focused_index = next; - return; - } - } - } - pub fn toggle_focused(&mut self) -> CheckboxChange { - let Some(item) = self.items.get(self.focused_index) else { - return CheckboxChange::NoItem; - }; - let value = item.value.clone(); - if !item.enabled { - return CheckboxChange::IgnoredDisabled(value); - } - if self.selected.remove(&value) { - CheckboxChange::Deselected(value) - } else { - self.selected.insert(value.clone()); - CheckboxChange::Selected(value) - } - } - pub fn set_enabled(&mut self, value: &T, enabled: bool) { - if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) { - item.enabled = enabled; - } - } - pub fn set_selected(&mut self, value: T, selected: bool) { - if selected { - self.selected.insert(value); - } else { - self.selected.remove(&value); - } - } - pub fn visual_state(&self, value: &T) -> ChoiceVisualState { - let item = self.items.iter().position(|item| &item.value == value); - ChoiceVisualState { - selected: self.selected.contains(value), - focused: item == Some(self.focused_index), - enabled: item - .and_then(|index| self.items.get(index)) - .is_some_and(|item| item.enabled), - } - } -} diff --git a/iota-cli/src/controls/choice.rs b/iota-cli/src/controls/choice.rs deleted file mode 100644 index 713971b..0000000 --- a/iota-cli/src/controls/choice.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::theme::ResolvedTheme; -use ratatui::text::{Line, Span}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChoiceKind { - Checkbox, - Radio, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ChoiceVisualState { - pub selected: bool, - pub focused: bool, - pub enabled: bool, -} -pub fn render_choice_line<'a>( - label: &'a str, - kind: ChoiceKind, - state: ChoiceVisualState, - theme: &'a ResolvedTheme, -) -> Line<'a> { - let item = match (state.selected, state.focused, state.enabled) { - (_, true, false) => &theme.choices.focused_disabled, - (true, false, false) => &theme.choices.selected_disabled, - (false, false, false) => &theme.choices.disabled, - (true, true, true) => &theme.choices.focused_selected, - (true, false, true) => &theme.choices.selected, - (false, true, true) => &theme.choices.focused, - (false, false, true) => &theme.choices.normal, - }; - let marker = match (kind, state.selected) { - (ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected, - (ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected, - (ChoiceKind::Radio, false) => theme.markers.radio_unselected, - (ChoiceKind::Radio, true) => theme.markers.radio_selected, - }; - Line::from(vec![ - Span::styled(item.prefix, item.label), - Span::styled(marker, item.marker), - Span::raw(" "), - Span::styled(label, item.label), - Span::styled(item.suffix, item.label), - ]) -} diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs deleted file mode 100644 index 3756641..0000000 --- a/iota-cli/src/controls/dialog.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, KeyHint, Screen, UiEvent}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DialogButton { - Cancel, - Confirm, - Custom(usize), -} - -pub struct ConfirmDialog { - title: String, - message: Vec, - buttons: Vec, - focused_button: usize, - on_confirm: Option InteractionResult + Send + Sync>>, - on_cancel: Option InteractionResult + Send + Sync>>, -} - -struct DialogButtonConfig { - label: String, - intent: ButtonIntent, - enabled: bool, -} - -impl ConfirmDialog { - pub fn new(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Confirm".to_owned(), - intent: ButtonIntent::Primary, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn destructive(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Delete".to_owned(), - intent: ButtonIntent::Destructive, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn with_message_line(mut self, line: impl Into) -> Self { - self.message.push(line.into()); - self - } - - pub fn with_button(mut self, label: impl Into, intent: ButtonIntent) -> Self { - self.buttons.push(DialogButtonConfig { - label: label.into(), - intent, - enabled: true, - }); - self - } - - pub fn with_confirm_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_confirm = Some(Box::new(action)); - self - } - - pub fn with_cancel_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_cancel = Some(Box::new(action)); - self - } - - fn activate(&self) -> InteractionResult { - match self.focused_button { - 0 => { - if let Some(action) = &self.on_cancel { - action() - } else { - InteractionResult::CloseScreen - } - } - 1 => { - if let Some(action) = &self.on_confirm { - action() - } else { - InteractionResult::CloseScreen - } - } - _ => InteractionResult::CloseScreen, - } - } - - fn next_button(&mut self) { - self.focused_button = (self.focused_button + 1) % self.buttons.len(); - } - - fn prev_button(&mut self) { - if self.focused_button == 0 { - self.focused_button = self.buttons.len() - 1; - } else { - self.focused_button -= 1; - } - } -} - -impl Screen for ConfirmDialog { - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 50, - height: (self.message.len() + 8) as u16, - }, - ); - - f.render_widget(Clear, area); - let block = Block::default() - .title(format!(" {} ", self.title)) - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - - let inner = block.inner(area); - f.render_widget(block, area); - - let rows = Layout::vertical([ - Constraint::Min(self.message.len() as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - let lines: Vec = self - .message - .iter() - .map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal))) - .collect(); - f.render_widget(Paragraph::new(lines), rows[0]); - - let buttons_area = rows[2]; - let button_widths: Vec = self - .buttons - .iter() - .map(|b| crate::controls::button::button_minimum_width(&b.label)) - .collect(); - - let total_width: u16 = button_widths.iter().sum(); - let spacing = self.buttons.len().saturating_sub(1) as u16; - let available = buttons_area.width; - let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; - - let mut x = start_x; - for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() { - let button_area = Rect { - x, - y: buttons_area.y, - width, - height: 1, - }; - x = x.saturating_add(width + 1); - - render_button( - f, - button_area, - ActionButton { - label: &button_config.label, - intent: button_config.intent, - focused: self.focused_button == i, - enabled: button_config.enabled, - }, - context.theme, - ); - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Tab => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Left => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Right => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Tab", - action: "Switch button", - }, - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } -} diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs deleted file mode 100644 index c38eda4..0000000 --- a/iota-cli/src/controls/header.rs +++ /dev/null @@ -1,164 +0,0 @@ -use crate::ipc_client::{DaemonStatus, IpcConnectionState}; -use crate::theme::ResolvedTheme; -use crate::{ - controls::button::ButtonIntent, - screens::screens::{AppAction, HitMap}, -}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::Paragraph, -}; - -fn connection_badge( - state: &IpcConnectionState, - theme: &ResolvedTheme, -) -> (&'static str, ratatui::style::Style) { - match state { - IpcConnectionState::Connected => ("OK", theme.status.success), - IpcConnectionState::Connecting => ("..", theme.status.warning), - IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { - ("FAIL", theme.status.error) - } - IpcConnectionState::Disconnected => ("WARN", theme.status.warning), - } -} - -fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) { - use iota_ipc::ComponentId; - let health = daemon.components.get(&ComponentId::Omikron); - let (label, style) = match health.map(|h| h.status) { - Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success), - Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning), - Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error), - None => ("--", theme.text.muted), - }; - let detail = health - .and_then(|h| h.message.as_deref()) - .map(|m| format!(" {m}")) - .unwrap_or_default(); - (format!("{label}{detail}"), style) -} - -pub fn render_header( - frame: &mut Frame, - area: Rect, - connection: &IpcConnectionState, - daemon: &DaemonStatus, - theme: &ResolvedTheme, - hits: &mut HitMap, - focused_action: Option, -) { - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - - let rows = - Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); - let cells = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(rows[0]); - let cells2 = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(rows[1]); - - let (ipc_label, ipc_style) = connection_badge(connection, theme); - let (omikron_text, omikron_style) = omikron_badge(daemon, theme); - - let brand_line1 = Line::from(vec![ - Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar), - Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style), - ]); - let brand_line2 = Line::from(vec![ - Span::styled(" Omikron: ", theme.surfaces.toolbar), - Span::styled(format!("[{omikron_text}]"), omikron_style), - ]); - let brand_area = Rect { - x: area.x, - y: area.y, - width: cells[0].width, - height: area.height, - }; - frame.render_widget( - Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar), - brand_area, - ); - hits.register(brand_area, AppAction::OpenMain); - - for (index, (top, _bottom, label, intent, action)) in [ - ( - cells[1], - cells2[1], - "Overview", - ButtonIntent::Primary, - AppAction::OpenOverview, - ), - ( - cells[2], - cells2[2], - "Users", - ButtonIntent::Neutral, - AppAction::OpenUsers, - ), - ( - cells[3], - cells2[3], - "Settings", - ButtonIntent::Neutral, - AppAction::OpenSettings, - ), - ( - cells[4], - cells2[4], - "Quit", - ButtonIntent::Destructive, - AppAction::Quit, - ), - ] - .into_iter() - .enumerate() - { - let button_area = Rect { - x: top.x, - y: top.y, - width: top.width, - height: area.height, - }; - let style = match (intent, focused_action == Some(index)) { - (ButtonIntent::Primary, true) => theme.buttons.primary_focused, - (ButtonIntent::Primary, false) => theme.buttons.primary, - (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, - (ButtonIntent::Neutral, false) => theme.buttons.neutral, - (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, - (ButtonIntent::Cancel, false) => theme.buttons.cancel, - (ButtonIntent::Destructive, _) => theme.buttons.destructive, - }; - let display_label = if focused_action == Some(index) { - format!("› {label}") - } else { - label.to_owned() - }; - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled(display_label, style)), - Line::from(""), - ]), - button_area, - ); - hits.register(button_area, action); - } -} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs deleted file mode 100644 index 02eaa84..0000000 --- a/iota-cli/src/controls/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod action; -pub mod button; -pub mod checkbox_group; -pub mod choice; -pub mod dialog; -pub mod header; -pub mod navigation; -pub mod panel; -pub mod radio_group; -pub mod scroll; diff --git a/iota-cli/src/controls/navigation.rs b/iota-cli/src/controls/navigation.rs deleted file mode 100644 index 0f292c5..0000000 --- a/iota-cli/src/controls/navigation.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum DisabledFocusPolicy { - Include, - #[default] - Skip, -} diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs deleted file mode 100644 index 7307a03..0000000 --- a/iota-cli/src/controls/panel.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::theme::{ChromeMode, ResolvedTheme}; -use ratatui::{ - Frame, - layout::Rect, - widgets::{Block, Borders, Paragraph}, -}; - -/// Draw a conventional outlined panel or a filled surface from the same call -/// site. Screens can migrate without embedding theme branches in layouts. -pub fn render_panel( - frame: &mut Frame, - area: Rect, - title: &str, - focused: bool, - theme: &ResolvedTheme, -) -> Rect { - match theme.chrome { - ChromeMode::Bordered => { - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(if focused { - theme.borders.focused - } else { - theme.borders.normal - }); - let inner = block.inner(area); - frame.render_widget(block, area); - inner - } - ChromeMode::Surfaces => { - frame.render_widget( - Block::default().style(if focused { - theme.surfaces.panel_focused - } else { - theme.surfaces.panel - }), - area, - ); - let header = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height.min(1), - }; - frame.render_widget( - Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), - header, - ); - // Surface panels use a single header row. A one-cell inset keeps - // compact controls such as the console usable at height three. - Rect { - x: area.x.saturating_add(1), - y: area.y.saturating_add(1), - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(1), - } - } - } -} diff --git a/iota-cli/src/controls/radio_group.rs b/iota-cli/src/controls/radio_group.rs deleted file mode 100644 index 9aaacb5..0000000 --- a/iota-cli/src/controls/radio_group.rs +++ /dev/null @@ -1,194 +0,0 @@ -use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; - -pub struct RadioItem { - pub value: T, - pub label: String, - pub description: Option, - pub enabled: bool, - pub disabled_reason: Option, -} -pub struct RadioGroup { - items: Vec>, - selected: T, - default: T, - focused_index: usize, - focus_policy: DisabledFocusPolicy, - wrap_navigation: bool, - disabled_selection_policy: DisabledSelectionPolicy, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadioGroupError { - Empty, - DefaultMissing, - DefaultDisabled, - NoEnabledItems, - SelectedItemDisabled, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadioChange { - Changed { previous: T, selected: T }, - Unchanged(T), - IgnoredDisabled(T), -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisabledSelectionPolicy { - UseConfiguredDefault, - UseFirstEnabled, - ReturnError, -} -impl RadioGroup { - pub fn new( - items: Vec>, - observed: Option, - default: T, - ) -> Result { - if items.is_empty() { - return Err(RadioGroupError::Empty); - } - let default_item = items - .iter() - .find(|item| item.value == default) - .ok_or(RadioGroupError::DefaultMissing)?; - if !default_item.enabled { - return Err(RadioGroupError::DefaultDisabled); - } - let focused_index = items - .iter() - .position(|item| item.enabled) - .ok_or(RadioGroupError::NoEnabledItems)?; - let selected = observed - .filter(|value| { - items - .iter() - .any(|item| item.enabled && item.value == *value) - }) - .unwrap_or_else(|| default.clone()); - Ok(Self { - items, - selected, - default, - focused_index, - focus_policy: DisabledFocusPolicy::Skip, - wrap_navigation: true, - disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault, - }) - } - pub fn items(&self) -> &[RadioItem] { - &self.items - } - pub fn selected(&self) -> &T { - &self.selected - } - pub fn focused_item(&self) -> &RadioItem { - &self.items[self.focused_index] - } - pub fn focus_next(&mut self) { - self.move_focus(true); - } - pub fn focus_previous(&mut self) { - self.move_focus(false); - } - fn move_focus(&mut self, forward: bool) { - for step in 1..=self.items.len() { - let raw = self.focused_index as isize - + if forward { - step as isize - } else { - -(step as isize) - }; - let next = if self.wrap_navigation { - raw.rem_euclid(self.items.len() as isize) as usize - } else if raw < 0 || raw >= self.items.len() as isize { - return; - } else { - raw as usize - }; - if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { - self.focused_index = next; - return; - } - } - } - pub fn select_focused(&mut self) -> RadioChange { - let item = self.focused_item(); - let enabled = item.enabled; - let value = item.value.clone(); - if !enabled { - return RadioChange::IgnoredDisabled(value); - } - if value == self.selected { - RadioChange::Unchanged(self.selected.clone()) - } else { - let previous = std::mem::replace(&mut self.selected, value); - RadioChange::Changed { - previous, - selected: self.selected.clone(), - } - } - } - pub fn visual_state(&self, value: &T) -> ChoiceVisualState { - let item = self.items.iter().position(|item| &item.value == value); - ChoiceVisualState { - selected: &self.selected == value, - focused: item == Some(self.focused_index), - enabled: item - .and_then(|index| self.items.get(index)) - .is_some_and(|item| item.enabled), - } - } - pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) { - self.disabled_selection_policy = policy; - } - pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { - self.focus_policy = policy; - } - pub fn set_wrap_navigation(&mut self, wrap: bool) { - self.wrap_navigation = wrap; - } - pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> { - let Some(index) = self.items.iter().position(|item| &item.value == value) else { - return Ok(()); - }; - if self.items[index].enabled == enabled { - return Ok(()); - } - if !enabled - && self - .items - .iter() - .enumerate() - .all(|(other, item)| other == index || !item.enabled) - { - return Err(RadioGroupError::NoEnabledItems); - } - if !enabled && self.selected == *value { - let replacement = match self.disabled_selection_policy { - DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self - .items - .iter() - .find(|item| item.enabled && item.value == self.default) - .map(|item| item.value.clone()), - DisabledSelectionPolicy::UseConfiguredDefault => None, - DisabledSelectionPolicy::UseFirstEnabled => self - .items - .iter() - .enumerate() - .find(|(other, item)| *other != index && item.enabled) - .map(|(_, item)| item.value.clone()), - DisabledSelectionPolicy::ReturnError => { - return Err(RadioGroupError::SelectedItemDisabled); - } - }; - self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?; - } - self.items[index].enabled = enabled; - if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip - { - self.focus_next(); - } - Ok(()) - } - pub fn default(&self) -> &T { - &self.default - } -} diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs deleted file mode 100644 index 585bf3c..0000000 --- a/iota-cli/src/controls/scroll.rs +++ /dev/null @@ -1,53 +0,0 @@ -use ratatui::{ - Frame, - layout::Rect, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, -}; - -/// Reusable viewport policy for long, vertically stacked terminal content. -#[derive(Clone, Copy, Debug)] -pub struct ScrollOptions { - pub show_scrollbar: bool, - pub render_partial_components: bool, -} -impl Default for ScrollOptions { - fn default() -> Self { - Self { - show_scrollbar: true, - render_partial_components: true, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct ScrollField { - pub offset: u16, - pub options: ScrollOptions, -} -impl ScrollField { - pub fn up(&mut self, amount: u16) { - self.offset = self.offset.saturating_sub(amount); - } - pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { - self.offset = (self.offset.saturating_add(amount)) - .min(content_height.saturating_sub(viewport_height)); - } - pub fn render( - &self, - frame: &mut Frame, - area: Rect, - content: Paragraph<'_>, - content_height: u16, - ) { - frame.render_widget(content.scroll((self.offset, 0)), area); - if self.options.show_scrollbar && content_height > area.height { - let mut state = - ScrollbarState::new(content_height as usize).position(self.offset as usize); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight), - area, - &mut state, - ); - } - } -} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs deleted file mode 100644 index db861e6..0000000 --- a/iota-cli/src/elements/console_card.rs +++ /dev/null @@ -1,519 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; - -use std::{ - any::Any, - sync::{Arc, Mutex}, - time::Duration, -}; -use tokio::time::Instant; - -use crate::{ - elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, - ipc_client::IpcClient, - render_context::RenderContext, - util::borders::draw_block_joins, -}; - -pub struct ConsoleCard { - ipc: Arc, - focused: bool, - pub title: String, - pub content: String, - pub cursor_position: usize, - - borders: Borders, - joins: Borders, - - cursor: Arc>, - last_swap: Arc>, - pending_restore: Arc>>, - pending_confirmation: Option, - history: Vec, - history_index: Option, - history_draft: String, - message: Option, -} - -impl ConsoleCard { - pub fn new(title: &str, content: &str, ipc: Arc) -> Self { - ConsoleCard { - ipc, - focused: false, - title: title.to_string(), - content: content.to_string(), - cursor_position: content.chars().count(), - borders: Borders::ALL, - joins: Borders::NONE, - cursor: Arc::new(Mutex::new(true)), - last_swap: Arc::new(Mutex::new(Instant::now())), - pending_restore: Arc::new(Mutex::new(None)), - pending_confirmation: None, - history: Vec::new(), - history_index: None, - history_draft: String::new(), - message: None, - } - } - - fn byte_index(&self) -> usize { - self.content - .char_indices() - .nth(self.cursor_position) - .map(|(i, _)| i) - .unwrap_or(self.content.len()) - } - - fn cursor_visible(&self) -> bool { - if !self.focused { - return false; - } - - let mut visible = self.cursor.lock().unwrap(); - let mut last = self.last_swap.lock().unwrap(); - let now = Instant::now(); - - if now.duration_since(*last) >= Duration::from_millis(500) { - *visible = !*visible; - *last = now; - } - - *visible - } - - fn current_prefix(&self) -> Option<&str> { - if self.content.starts_with('/') { - Some("/") - } else { - None - } - } - - fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { - let cursor_visible = self.cursor_visible(); - let mut spans = Vec::new(); - - if self.content.is_empty() { - if self.focused { - if cursor_visible { - Self::push_cursor(&mut spans, theme); - } else { - spans.push(Span::styled(" ", theme.console.text)); - } - spans.push(Span::styled( - "send command (/help for info)", - theme.console.hint, - )); - } else { - spans.push(Span::styled( - " send command (/help for info)", - theme.console.hint, - )); - } - return spans; - } - - let byte_index = self.byte_index(); - let before = self.content[..byte_index].to_string(); - let after = self.content[byte_index..].to_string(); - - let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0); - - if prefix_len > 0 && before.len() >= prefix_len { - let prefix = &before[..prefix_len]; - let rest = &before[prefix_len..]; - spans.push(Span::styled(prefix.to_string(), theme.console.prefix)); - if !rest.is_empty() { - spans.push(Span::styled(rest.to_string(), theme.console.text)); - } - } else if !before.is_empty() { - spans.push(Span::styled(before.clone(), theme.console.text)); - } - - if cursor_visible { - Self::push_cursor(&mut spans, theme); - } - - if !after.is_empty() { - spans.push(Span::styled(after, theme.console.text)); - } - - spans - } - - fn push_cursor(spans: &mut Vec>, theme: &crate::theme::ResolvedTheme) { - match &theme.console.cursor { - crate::theme::CursorPresentation::StyledCell(style) => { - spans.push(Span::styled(" ", *style)) - } - crate::theme::CursorPresentation::Character { glyph, style } => { - spans.push(Span::styled(*glyph, *style)) - } - } - } - - fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { - if let Some(message) = &self.message { - return vec![Span::styled(message.clone(), theme.console.error)]; - } - if let Some(command) = &self.pending_confirmation { - return vec![Span::styled( - format!("Confirm `{command}`? [y/N]"), - theme.console.confirmation, - )]; - } - self.cursor_spans(theme) - } - - fn is_destructive(command: &str) -> bool { - matches!( - command.trim_start_matches('/').trim(), - "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate" - ) || command - .trim_start_matches('/') - .trim_start() - .split_once(" remove ") - .is_some_and(|(noun, _)| matches!(noun, "user" | "users")) - } - - fn dispatch_command(&self, command: String) { - let ipc = self.ipc.clone(); - let restore = self.pending_restore.clone(); - tokio::spawn(async move { - if ipc.send_command(0, command.clone()).await.is_err() { - *restore.lock().unwrap() = Some(command); - } - }); - } - - fn move_cursor_left(&mut self) { - if self.cursor_position > 0 { - self.cursor_position -= 1; - } - } - - fn move_cursor_right(&mut self) { - let len = self.content.chars().count(); - if self.cursor_position < len { - self.cursor_position += 1; - } - } - - fn delete_at_cursor(&mut self) { - if self.content.is_empty() || self.cursor_position == 0 { - return; - } - - let start = self - .content - .char_indices() - .nth(self.cursor_position.saturating_sub(1)) - .map(|(i, _)| i) - .unwrap_or(0); - let end = self.byte_index(); - self.content.replace_range(start..end, ""); - self.cursor_position -= 1; - } - - fn insert_at_cursor(&mut self, c: char) { - let idx = self.byte_index(); - self.content.insert(idx, c); - self.cursor_position += 1; - } - - pub fn handle_paste(&mut self, text: &str) { - let sanitized = text.replace(['\r', '\n'], " "); - let index = self.byte_index(); - self.content.insert_str(index, &sanitized); - self.cursor_position += sanitized.chars().count(); - self.message = None; - } - - fn set_editor(&mut self, value: String) { - self.content = value; - self.cursor_position = self.content.chars().count(); - } - - fn history_previous(&mut self) { - if self.history.is_empty() { - return; - } - let index = match self.history_index { - None => { - self.history_draft = self.content.clone(); - self.history.len() - 1 - } - Some(index) => index.saturating_sub(1), - }; - self.history_index = Some(index); - self.set_editor(self.history[index].clone()); - self.message = None; - } - - fn history_next(&mut self) { - let Some(index) = self.history_index else { - return; - }; - if index + 1 < self.history.len() { - self.history_index = Some(index + 1); - self.set_editor(self.history[index + 1].clone()); - } else { - self.history_index = None; - let draft = std::mem::take(&mut self.history_draft); - self.set_editor(draft); - } - self.message = None; - } - - fn complete(&mut self) -> bool { - let completions = iota_ipc::text_commands::completions(&self.content); - if completions.len() == 1 { - let leading_slash = self.content.starts_with('/'); - self.set_editor(format!( - "{}{}", - if leading_slash { "/" } else { "" }, - completions[0] - )); - self.message = None; - true - } else if completions.len() > 1 { - self.message = Some(format!("Matches: {}", completions.join(", "))); - true - } else { - false - } - } -} - -impl Element for ConsoleCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { - if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - let inner = crate::controls::panel::render_panel( - f, - r, - &self.title, - self.focused, - context.theme, - ); - f.render_widget( - Paragraph::new(Line::from(self.render_cursor_spans(context.theme))) - .style(context.theme.console.text), - inner, - ); - return; - } - let block = Block::default() - .borders(self.borders) - .title(self.title.clone()) - .title_style(context.theme.console.title) - .border_style(if self.focused { - context.theme.console.focused_border - } else { - context.theme.console.border - }) - .style(context.theme.console.text); - - let spans = self.render_cursor_spans(context.theme); - let par = Paragraph::new(Line::from(spans)) - .block(block) - .scroll((0, 0)); - f.render_widget(par, r); - draw_block_joins( - f, - r, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); - } -} - -impl JoinableElement for ConsoleCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn as_element(&self) -> &(dyn Element + 'static) { - self - } - - fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { - self - } - - fn set_borders(&mut self, borders: Borders) { - self.borders = borders; - } - - fn set_joins(&mut self, joins: Borders) { - self.joins = joins; - } -} - -impl InteractableElement for ConsoleCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn as_element(&self) -> &(dyn Element + 'static) { - self - } - - fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { - self - } - - fn interact(&mut self, key: KeyEvent) -> InteractionResult { - // Check if a previously failed command should be restored. - if let Some(restored) = self.pending_restore.lock().unwrap().take() { - self.content = restored; - self.cursor_position = self.content.chars().count(); - self.message = Some("Command failed; restored for retry.".into()); - } - - if let Some(command) = self.pending_confirmation.take() { - if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) { - self.dispatch_command(command); - } - return InteractionResult::Handled; - } - - match key.code { - KeyCode::Enter => { - if self.content.is_empty() { - return InteractionResult::Handled; - } - - let command = self.content.clone(); - if let Some(error) = iota_ipc::text_commands::validation_error(&command) { - self.message = Some(error); - return InteractionResult::Handled; - } - if command.trim_start_matches('/').trim() == "help" { - self.message = Some(format!( - "Commands: {}", - iota_ipc::text_commands::COMMANDS.join(", ") - )); - return InteractionResult::Handled; - } - if self.history.last() != Some(&command) { - self.history.push(command.clone()); - } - self.history_index = None; - self.history_draft.clear(); - self.content.clear(); - self.cursor_position = 0; - if Self::is_destructive(&command) { - self.pending_confirmation = Some(command); - } else { - self.dispatch_command(command); - } - InteractionResult::Handled - } - KeyCode::Backspace => { - self.message = None; - self.delete_at_cursor(); - InteractionResult::Handled - } - KeyCode::Delete => { - self.message = None; - let len = self.content.chars().count(); - if self.cursor_position < len { - let start = self.byte_index(); - let end = self - .content - .char_indices() - .nth(self.cursor_position + 1) - .map(|(i, _)| i) - .unwrap_or(self.content.len()); - self.content.replace_range(start..end, ""); - } - InteractionResult::Handled - } - KeyCode::Left => { - self.move_cursor_left(); - InteractionResult::Handled - } - KeyCode::Right => { - self.move_cursor_right(); - InteractionResult::Handled - } - KeyCode::Home => { - self.cursor_position = 0; - InteractionResult::Handled - } - KeyCode::End => { - self.cursor_position = self.content.chars().count(); - InteractionResult::Handled - } - KeyCode::Up => { - self.history_previous(); - InteractionResult::Handled - } - KeyCode::Down => { - self.history_next(); - InteractionResult::Handled - } - KeyCode::Tab if !self.content.is_empty() => { - if self.complete() { - InteractionResult::Handled - } else { - self.message = Some("No command completion.".into()); - InteractionResult::Handled - } - } - KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, - _ => { - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - { - if let Some(c) = key.code.as_char() { - self.insert_at_cursor(c); - self.message = None; - return InteractionResult::Handled; - } - } - InteractionResult::Unhandled - } - } - } - - fn can_focus(&self) -> bool { - true - } - - fn is_focused(&self) -> bool { - self.focused - } - - fn focus(&mut self, f: bool) { - self.focused = f; - } -} diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs deleted file mode 100644 index f47aab6..0000000 --- a/iota-cli/src/elements/graph_card.rs +++ /dev/null @@ -1,287 +0,0 @@ -use std::{any::Any, sync::Arc}; - -use crossterm::event::KeyEvent; -use iota_state::ClientState; -use ratatui::{ - Frame, - layout::Rect, - widgets::{ - Block, Borders, - canvas::{Canvas, Line}, - }, -}; - -use crate::{ - elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, - render_context::RenderContext, - ui::UI, - util::borders::draw_block_joins, -}; - -pub enum GRAPHS { - Ram, - Cpu, - Ping, -} - -impl GRAPHS { - pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color { - match self { - GRAPHS::Ram => theme.graphs.ram, - GRAPHS::Cpu => theme.graphs.cpu, - GRAPHS::Ping => theme.graphs.ping, - } - } - - pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> { - let state = match state.app.try_lock() { - Ok(state) => state, - Err(_) => return Vec::new(), - }; - match self { - GRAPHS::Ram => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .ram - .clone(), - GRAPHS::Cpu => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .cpu - .clone(), - GRAPHS::Ping => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .ping - .clone(), - } - } - - pub fn get_unit(&self) -> String { - match self { - // Memory is collected as a percentage of total RAM, not MiB. - GRAPHS::Ram => "%".to_string(), - GRAPHS::Cpu => "%".to_string(), - GRAPHS::Ping => "ms".to_string(), - } - } -} - -#[allow(unused)] -pub struct GraphCard { - ui: Arc, - state: ClientState, - graph_type: GRAPHS, - - focused: bool, - pub title: String, - - borders: Borders, - joins: Borders, - - open: bool, - sample_width: usize, -} - -impl GraphCard { - pub fn new(ui: Arc, state: ClientState, graph_type: GRAPHS, title: String) -> Self { - Self { - ui, - state, - graph_type, - focused: false, - title, - borders: Borders::ALL, - joins: Borders::NONE, - open: true, - sample_width: 28, - } - } - - pub fn set_open(&mut self, open: bool) { - self.open = open; - } - - pub fn set_sample_width(&mut self, sample_width: usize) { - self.sample_width = sample_width.max(1); - } -} -impl Element for GraphCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { - if self.open { - let graph = self.graph_type.get_graph(&self.state, self.sample_width); - if graph.is_empty() { - let block = Block::default() - .title(format!(" {} ", self.title)) - .borders(self.borders) - .border_style(if self.focused { - context.theme.graphs.focused_border - } else { - context.theme.graphs.border - }); - f.render_widget( - ratatui::widgets::Paragraph::new("No metric samples yet.") - .style(context.theme.text.muted) - .block(block), - r, - ); - return; - } - let unit = self.graph_type.get_unit(); - let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); - let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); - let max_x = if max_x <= min_x { min_x + 1.0 } else { max_x }; - let min_y = graph - .iter() - .map(|(_, y)| *y) - .filter(|y| *y > 0.0) - .min_by(|a, b| a.total_cmp(b)) - .unwrap_or(0.0); - let max_y = graph.iter().map(|(_, y)| *y).fold(0.0, f64::max); - let y_upper = match self.graph_type { - GRAPHS::Cpu | GRAPHS::Ram => 100.0, - GRAPHS::Ping => (max_y * 1.2).max(10.0), - }; - - let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); - let title = format!( - "{}: {}{} {}min/{}max", - self.title, - graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, - unit, - min_y as i64, - max_y as i64 - ); - let plot_area = if surface { - crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) - } else { - r - }; - let block = Block::default() - .title(if surface { - String::new() - } else { - format!( - "{}:─{}{}─{}min/{}max", - self.title, - graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, - unit, - min_y as i64, - max_y as i64, - ) - }) - .borders(if surface { Borders::NONE } else { self.borders }) - .border_style(if self.focused { - context.theme.graphs.focused_border - } else { - context.theme.graphs.border - }); - - let canvas = Canvas::default() - .block(block) - .x_bounds([min_x, max_x]) - .y_bounds([0.0, y_upper]) - .paint(|ctx| { - for (x, y) in &graph { - ctx.draw(&Line { - x1: *x, - y1: 0.0, - x2: *x, - y2: *y, - color: self.graph_type.get_color(context.theme), - }); - } - }); - f.render_widget(canvas, plot_area); - } else { - let block = Block::default() - .title("") - .borders(self.borders) - .border_style(if self.focused { - context.theme.graphs.focused_border - } else { - context.theme.graphs.border - }); - f.render_widget(block, r); - } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - draw_block_joins( - f, - r, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); - } - } -} - -impl JoinableElement for GraphCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn as_element(&self) -> &dyn Element { - self - } - - fn as_element_mut(&mut self) -> &mut dyn Element { - self - } - - fn set_borders(&mut self, borders: Borders) { - self.borders = borders; - } - - fn set_joins(&mut self, joins: Borders) { - self.joins = joins; - } -} - -impl InteractableElement for GraphCard { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn as_element(&self) -> &dyn Element { - self - } - - fn as_element_mut(&mut self) -> &mut dyn Element { - self - } - - fn interact(&mut self, _key: KeyEvent) -> InteractionResult { - InteractionResult::Handled - } - - fn can_focus(&self) -> bool { - true - } - - fn is_focused(&self) -> bool { - self.focused - } - - fn focus(&mut self, f: bool) { - self.focused = f; - } -} diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs deleted file mode 100644 index 007c223..0000000 --- a/iota-cli/src/help_overlay.rs +++ /dev/null @@ -1,219 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, - theme::ResolvedTheme, -}; - -pub struct HelpOverlay { - scroll: usize, -} - -impl HelpOverlay { - pub fn new() -> Self { - Self { scroll: 0 } - } - - fn build_lines(&self, theme: &ResolvedTheme) -> Vec> { - vec![ - Line::from(""), - Line::from(Span::styled( - "Global Keyboard Shortcuts", - theme.text.heading, - )), - Line::from(""), - Line::from(vec![ - Span::styled(" F6", theme.text.link), - Span::styled(" Toggle header navigation", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Tab", theme.text.link), - Span::styled(" Move focus to next panel", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Shift+Tab", theme.text.link), - Span::styled(" Move focus to previous panel", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Esc", theme.text.link), - Span::styled(" Go back / Close dialog", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Ctrl+C", theme.text.link), - Span::styled(" Quit the application", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Dashboard Navigation", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" o/O", theme.text.link), - Span::styled(" Open Overview screen", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" u/U", theme.text.link), - Span::styled(" Open Users screen", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" m/M", theme.text.link), - Span::styled(" Open Metrics screen", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Log Panel", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" j/Down", theme.text.link), - Span::styled(" Scroll down", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" k/Up", theme.text.link), - Span::styled(" Scroll up", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Enter", theme.text.link), - Span::styled(" Lock/unlock scroll", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /", theme.text.link), - Span::styled(" Filter logs", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("Console Panel", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" Enter", theme.text.link), - Span::styled(" Send command", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Up/Down", theme.text.link), - Span::styled(" Command history", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Tab", theme.text.link), - Span::styled(" Auto-complete", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /help", theme.text.link), - Span::styled(" List available commands", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled("List Navigation", theme.text.heading)), - Line::from(""), - Line::from(vec![ - Span::styled(" j/Down", theme.text.link), - Span::styled(" Next item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" k/Up", theme.text.link), - Span::styled(" Previous item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" PgUp/PgDn", theme.text.link), - Span::styled(" Page up/down", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" Home", theme.text.link), - Span::styled(" First item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" End", theme.text.link), - Span::styled(" Last item", theme.text.normal), - ]), - Line::from(vec![ - Span::styled(" /", theme.text.link), - Span::styled(" Filter list", theme.text.normal), - ]), - Line::from(""), - Line::from(Span::styled( - "Press ? or Esc to close this overlay", - theme.text.muted, - )), - ] - } -} - -impl Screen for HelpOverlay { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 52, - height: 40, - }, - ); - - f.render_widget(Clear, area); - let block = Block::default() - .title(" Keyboard Shortcuts (?) ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - - let inner = block.inner(area); - f.render_widget(block, area); - - let lines = self.build_lines(context.theme); - let paragraph = Paragraph::new(lines) - .scroll((self.scroll as u16, 0)) - .style(context.theme.text.normal); - f.render_widget(paragraph, inner); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { - InteractionResult::CloseScreen - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll = self.scroll.saturating_add(1); - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - self.scroll = self.scroll.saturating_sub(1); - InteractionResult::Handled - } - KeyCode::PageDown => { - self.scroll = self.scroll.saturating_add(10); - InteractionResult::Handled - } - KeyCode::PageUp => { - self.scroll = self.scroll.saturating_sub(10); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - crate::screens::screens::KeyHint { - keys: "Up/Down", - action: "Scroll", - }, - crate::screens::screens::KeyHint { - keys: "Esc/?", - action: "Close", - }, - ] - } -} - -use std::any::Any; diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs deleted file mode 100644 index 842f89c..0000000 --- a/iota-cli/src/input_handler.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::{screens::screens::UiEvent, ui::UI}; -use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; - -pub fn setup_input_handler(ui: Arc) -> JoinHandle> { - tokio::spawn(async move { - let cancellation = ui.cancellation_token(); - let (tx, mut rx) = mpsc::unbounded_channel(); - let worker_cancellation = cancellation.clone(); - let worker = tokio::task::spawn_blocking(move || -> Result<(), String> { - while !worker_cancellation.is_cancelled() { - if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? { - tx.send(read().map_err(|e| e.to_string())?) - .map_err(|_| "input session closed".to_string())?; - } - } - Ok(()) - }); - loop { - if ui.is_shutdown() { - break; - } - - tokio::select! { - event = rx.recv() => match event { - Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, - Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await, - Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await, - Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await, - Some(_) => {}, - None => break, - }, - _ = cancellation.cancelled() => break, - } - } - let result = match worker.await { - Ok(result) => result, - Err(error) if error.is_cancelled() => Ok(()), - Err(error) => Err(format!("input worker failed: {error}")), - }; - if result.is_err() { - ui.request_shutdown(); - } - result - }) -} - -pub async fn handle_input(key: KeyEvent, ui: Arc) { - if matches!( - key.code, - crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c') - ) && key.modifiers.contains(KeyModifiers::CONTROL) - { - ui.request_shutdown(); - } else { - ui.handle_event(UiEvent::Key(key)).await; - } -} diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs deleted file mode 100644 index 45ca8fc..0000000 --- a/iota-cli/src/ipc_client.rs +++ /dev/null @@ -1,845 +0,0 @@ -use iota_ipc::{ - ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, - RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg, -}; -use iota_state::{ClientState, UiLogEntry}; -use std::collections::HashMap; -use std::io::Result; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Duration; -use tokio::net::UnixStream; -use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; -use tokio::sync::{Mutex, oneshot, watch}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -const INITIAL_BACKOFF: Duration = Duration::from_millis(200); -const MAX_BACKOFF: Duration = Duration::from_secs(10); -const MAX_RECONNECT_ATTEMPTS: u32 = 50; -const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); - -/// Connection state exposed to the UI. -#[derive(Clone, Debug)] -pub enum IpcConnectionState { - Connecting, - Connected, - Reconnecting { attempt: u32 }, - Incompatible { message: String }, - Failed { message: String }, - Disconnected, -} - -/// Daemon information shown by the UI. This is separate from socket connectivity: -/// a connected daemon may still be starting or degraded. -#[derive(Clone, Debug, Default)] -pub struct DaemonStatus { - pub version: String, - pub instance_id: String, - pub startup_phase: Option, - pub degraded_reason: Option, - pub lifecycle: Option, - pub health: iota_ipc::HealthStatus, - pub deployment_mode: Option, - pub supervisor: Option, - pub components: std::collections::BTreeMap, -} - -/// Pending request awaiting a response. -struct PendingRequest { - response_tx: oneshot::Sender, -} - -struct ActiveWriter { - generation: u64, - writer: OwnedWriteHalf, -} - -struct NegotiatedConnection { - reader: OwnedReadHalf, - writer: OwnedWriteHalf, - ack: HelloAck, - buffered_messages: Vec, -} - -/* The TUI owns this cache. IPC updates replace daemon snapshots and append - * logs, so rendering never reaches into daemon-owned storage or connections. */ -pub struct IpcClient { - state: ClientState, - writer: Mutex>, - next_generation: AtomicU64, - next_request_id: AtomicU64, - pending: Mutex>, - connection_state: watch::Sender, - daemon_status: watch::Sender, - path: PathBuf, - reconnector_started: AtomicBool, - cancellation: CancellationToken, - background_tasks: StdMutex>>, -} - -impl IpcClient { - pub async fn connect(path: impl AsRef) -> Result> { - let path = path.as_ref().to_path_buf(); - let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; - Self::connect_until(&path, deadline).await - } - - async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result> { - let path = path.to_path_buf(); - let stream = Self::connect_stream(&path, deadline).await?; - let negotiated = Self::negotiate_stream(stream, deadline).await?; - - // These values are visible before MainScreen subscribes. Do not - // publish the handshake into a channel with no retained receiver. - let initial_status = DaemonStatus { - version: negotiated.ack.daemon_version.clone(), - instance_id: negotiated.ack.instance_id.clone(), - startup_phase: Some(negotiated.ack.startup_phase), - degraded_reason: None, - lifecycle: Some(negotiated.ack.lifecycle), - health: negotiated.ack.health, - deployment_mode: Some(negotiated.ack.deployment_mode), - supervisor: Some(negotiated.ack.supervisor), - components: std::collections::BTreeMap::new(), - }; - let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); - let (daemon_status_tx, _) = watch::channel(initial_status); - let client = Arc::new(Self { - state: ClientState::new(), - writer: Mutex::new(Some(ActiveWriter { - generation: 1, - writer: negotiated.writer, - })), - next_generation: AtomicU64::new(2), - next_request_id: AtomicU64::new(1), - pending: Mutex::new(HashMap::new()), - connection_state: conn_state_tx, - daemon_status: daemon_status_tx, - path: path.clone(), - reconnector_started: AtomicBool::new(false), - cancellation: CancellationToken::new(), - background_tasks: StdMutex::new(Vec::new()), - }); - - // Apply messages received while waiting for subscription confirmation - // before exposing the connection to the UI. - for message in negotiated.buffered_messages { - client.apply(message).await; - } - - // Start reader task (continues reading after handshake) - let reader_client = client.clone(); - let task = tokio::spawn(async move { - reader_client.read_loop(negotiated.reader, 1).await; - }); - client.background_tasks.lock().unwrap().push(task); - - Ok(client) - } - - /// Try to connect with retries for socket activation. - pub async fn connect_or_activate(path: impl AsRef) -> Result> { - let path = path.as_ref().to_path_buf(); - let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; - let mut last_error = None; - while tokio::time::Instant::now() < deadline { - match Self::connect_until(&path, deadline).await { - Ok(client) => return Ok(client), - Err(error) => { - last_error = Some(error); - tokio::time::sleep(Duration::from_millis(250)).await; - } - } - } - Err(last_error.unwrap_or_else(|| { - std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon") - })) - } - - async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result { - tokio::time::timeout_at(deadline, UnixStream::connect(path)) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out connecting to daemon", - ) - })? - } - - async fn negotiate_stream( - stream: UnixStream, - deadline: tokio::time::Instant, - ) -> Result { - let (mut reader, mut writer) = stream.into_split(); - tokio::time::timeout_at( - deadline, - write_msg( - &mut writer, - &ClientMessage::Hello { - supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], - }, - ), - ) - .await - .map_err(|_| { - std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello") - })??; - let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)) - .await - { - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out waiting for IPC HelloAck", - )); - } - Ok(Ok(DaemonMessage::HelloAck(ack))) => ack, - Ok(Ok(_)) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Expected HelloAck as the first daemon message", - )); - } - Ok(Err(error)) => return Err(error), - }; - if !Self::is_compatible_version(ack.protocol_version) { - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - format!( - "Unsupported daemon protocol version {}", - ack.protocol_version - ), - )); - } - tokio::time::timeout_at( - deadline, - write_msg( - &mut writer, - &ClientMessage::Subscribe { - log_classes: vec![], - metric_interval_ms: Some(500), - }, - ), - ) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out sending IPC subscription", - ) - })??; - - // The daemon may send its initial StateUpdate before the acknowledgement. - // Keep draining until the subscription itself is confirmed, otherwise a - // UI can report Connected while no state stream exists yet. - let mut buffered_messages = Vec::new(); - loop { - match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await - { - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out waiting for IPC subscription acknowledgement", - )); - } - Ok(Ok(DaemonMessage::Subscribed)) => break, - Ok(Ok(message)) => buffered_messages.push(message), - Ok(Err(error)) => return Err(error), - } - } - - Ok(NegotiatedConnection { - reader, - writer, - ack, - buffered_messages, - }) - } - - /// Start the reconnection actor. - pub fn spawn_reconnector(self: &Arc) { - if self.reconnector_started.swap(true, Ordering::AcqRel) { - return; - } - let client = self.clone(); - let task = tokio::spawn(async move { - client.reconnection_loop().await; - }); - self.background_tasks.lock().unwrap().push(task); - } - - async fn reconnection_loop(self: Arc) { - let mut rx = self.connection_status(); - - loop { - while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) { - if tokio::select! { - changed = rx.changed() => changed.is_err(), - _ = self.cancellation.cancelled() => true, - } { - return; - } - } - - let mut backoff = INITIAL_BACKOFF; - for attempt in 1..=MAX_RECONNECT_ATTEMPTS { - if self.cancellation.is_cancelled() { - return; - } - let _ = self - .connection_state - .send(IpcConnectionState::Reconnecting { attempt }); - tokio::select! { - _ = tokio::time::sleep(backoff) => {}, - _ = self.cancellation.cancelled() => return, - } - let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; - let result = async { - let stream = Self::connect_stream(&self.path, deadline).await?; - Self::negotiate_stream(stream, deadline).await - } - .await; - match result { - Ok(connection) => { - self.install_connection(connection).await; - break; - } - Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { - let _ = self - .connection_state - .send(IpcConnectionState::Incompatible { - message: error.to_string(), - }); - return; - } - Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => { - let _ = self.connection_state.send(IpcConnectionState::Failed { - message: format!("Reconnect failed after {attempt} attempts: {error}"), - }); - return; - } - Err(error) => { - eprintln!( - "IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}", - self.path.display(), - error.kind() - ); - backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); - } - } - } - } - } - - async fn install_connection(self: &Arc, connection: NegotiatedConnection) { - let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); - *self.writer.lock().await = Some(ActiveWriter { - generation, - writer: connection.writer, - }); - self.update_hello_ack(connection.ack); - for message in connection.buffered_messages { - self.apply(message).await; - } - let _ = self.connection_state.send(IpcConnectionState::Connected); - let client = self.clone(); - let task = tokio::spawn(async move { - client.read_loop(connection.reader, generation).await; - }); - self.background_tasks.lock().unwrap().push(task); - } - - async fn fail_pending_requests(&self) { - let mut pending = self.pending.lock().await; - for (_, request) in pending.drain() { - let _ = request - .response_tx - .send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)); - } - } - - async fn mark_disconnected(&self, generation: u64) { - let removed = { - let mut writer = self.writer.lock().await; - match writer.as_ref() { - Some(active) if active.generation == generation => { - writer.take(); - true - } - _ => false, - } - }; - if removed { - let _ = self.connection_state.send(IpcConnectionState::Disconnected); - self.fail_pending_requests().await; - } - } - - async fn read_loop(self: Arc, mut reader: OwnedReadHalf, generation: u64) { - loop { - let result = tokio::select! { - result = read_msg::<_, DaemonMessage>(&mut reader) => result, - _ = self.cancellation.cancelled() => break, - }; - match result { - Ok(message) => self.apply(message).await, - Err(_) => { - self.mark_disconnected(generation).await; - break; - } - } - } - } - - pub fn state(&self) -> ClientState { - self.state.clone() - } - - pub fn connection_status(&self) -> watch::Receiver { - self.connection_state.subscribe() - } - - pub fn connection_status_snapshot(&self) -> IpcConnectionState { - self.connection_state.borrow().clone() - } - - pub fn daemon_status(&self) -> watch::Receiver { - self.daemon_status.subscribe() - } - - /// Stop the IPC reader/reconnector and release the socket writer. This - /// is deliberately bounded so UI shutdown cannot hang on a peer. - pub async fn shutdown(&self) { - self.cancellation.cancel(); - self.writer.lock().await.take(); - let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap()); - for mut task in tasks { - if tokio::time::timeout(Duration::from_secs(2), &mut task) - .await - .is_err() - { - task.abort(); - } - } - } - - fn is_compatible_version(version: u16) -> bool { - (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version) - } - - fn update_hello_ack(&self, ack: HelloAck) { - self.daemon_status.send_modify(|status| { - status.version = ack.daemon_version; - status.instance_id = ack.instance_id; - status.startup_phase = Some(ack.startup_phase); - status.lifecycle = Some(ack.lifecycle); - status.health = ack.health; - status.deployment_mode = Some(ack.deployment_mode); - status.supervisor = Some(ack.supervisor); - }); - } - - fn format_payload(payload: &ResponsePayload) -> String { - match payload { - ResponsePayload::Status(status) => { - let mut msg = format!("Phase: {}", status.phase); - if !status.tasks.is_empty() { - msg.push_str(&format!(", Tasks: {}", status.tasks.join(", "))); - } - if let Some(reason) = &status.degraded_reason { - msg.push_str(&format!(", Degraded: {reason}")); - } - msg - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - "No active tasks.".into() - } else { - tasks - .iter() - .map(|t| t.name.as_str()) - .collect::>() - .join(", ") - } - } - ResponsePayload::Users(users) => { - if users.is_empty() { - "No users.".into() - } else { - users - .iter() - .map(|u| format!("{} ({})", u.username, u.user_id)) - .collect::>() - .join("\n") - } - } - ResponsePayload::UserCreated { user_id, username } => { - format!("Created user {} ({})", username, user_id) - } - ResponsePayload::UserRemoved { user_id } => { - format!("Removed user {}", user_id) - } - ResponsePayload::UserDataPurged { user_id } => { - format!("Purged hosted data for {}", user_id) - } - ResponsePayload::Acknowledged { message } => message.clone(), - ResponsePayload::DaemonStatus(status) => status.formatted.clone(), - ResponsePayload::Config(config) => config.yaml.clone(), - ResponsePayload::OmikronStatus(status) => { - let mut msg = format!("Connected: {}", status.connected); - if let Some(id) = status.iota_id { - msg.push_str(&format!("\nIota ID: {}", id)); - } - msg - } - ResponsePayload::Components(components) => { - if components.is_empty() { - "No component health data available.".into() - } else { - components - .iter() - .map(|c| { - let status_str = match c.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - format!("{:?}: {}", c.id, status_str) - }) - .collect::>() - .join("\n") - } - } - ResponsePayload::UserDetail(user) => { - let mut msg = format!("User: {} ({})", user.username, user.user_id); - if let Some(ref name) = user.display_name { - msg.push_str(&format!("\nDisplay Name: {name}")); - } - msg.push_str(&format!("\nCreated At: {}", user.created_at)); - if !user.trusted_apps.is_empty() { - msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); - } - msg - } - ResponsePayload::LogEntries(logs) => logs - .entries - .iter() - .map(|e| { - let level = if e.is_error { "ERR" } else { "INF" }; - format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) - }) - .collect::>() - .join("\n"), - ResponsePayload::UpdateStatus(status) => { - if status.available { - "Update available.".into() - } else { - "Up to date.".into() - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - "No communities.".into() - } else { - communities - .iter() - .map(|c| format!("{} ({})", c.title, c.name)) - .collect::>() - .join("\n") - } - } - } - } - - fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { - match code { - iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", - iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.", - iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.", - iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.", - iota_ipc::IpcErrorCode::OmikronUnavailable => { - "Omikron is unavailable; try reconnecting." - } - iota_ipc::IpcErrorCode::UnsupportedVersion => { - "CLI and daemon versions are incompatible." - } - iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.", - iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.", - iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.", - iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.", - iota_ipc::IpcErrorCode::Unauthorized => { - "The daemon rejected this operation: the connected IPC account lacks the required role. Use the configured operator socket or ask an administrator to grant access." - } - iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.", - } - } - - pub async fn send_request(&self, request: LocalRequest) -> Result { - let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let (response_tx, response_rx) = oneshot::channel(); - - { - let mut pending = self.pending.lock().await; - pending.insert(request_id, PendingRequest { response_tx }); - } - - let envelope = RequestEnvelope { - request_id, - protocol_version: PROTOCOL_VERSION, - request, - }; - if let Err(error) = self.send(ClientMessage::Request(envelope)).await { - self.pending.lock().await.remove(&request_id); - return Err(error); - } - - match tokio::time::timeout(Duration::from_secs(45), response_rx).await { - Ok(Ok(result)) => Ok(result), - Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), - Err(_) => { - self.pending.lock().await.remove(&request_id); - Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout)) - } - } - } - - /// Parse a legacy console command string into a typed request. - /// Delegates to the shared parser in iota-ipc. - pub fn parse_console_command(line: &str) -> Option { - iota_ipc::text_commands::parse(line) - } - - /// Legacy command interface: parse text command, send as typed request. - pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> { - let trimmed = line.trim_start_matches('/').trim(); - - // Handle ping as a direct Ping message (not a LocalRequest). - if trimmed == "ping" || trimmed.starts_with("ping ") { - let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed); - if let Err(e) = self.send(ClientMessage::Ping { seq }).await { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Console".into(), - message: format!("Failed to send ping: {}", e), - is_error: true, - }); - return Err(e); - } - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Console".into(), - message: "Ping sent".into(), - is_error: false, - }); - return Ok(()); - } - - if let Some(request) = Self::parse_console_command(&line) { - match self.send_request(request).await { - Ok(result) => { - let mut state = self.state.app.lock().await; - let message = match &result { - ResponseResult::Ok(payload) => Self::format_payload(payload), - ResponseResult::Error(code) => Self::format_error(code).into(), - }; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Command".into(), - message, - is_error: matches!(&result, ResponseResult::Error(_)), - }); - Ok(()) - } - Err(e) => { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Console".into(), - message: format!("Failed to send: {}", e), - is_error: true, - }); - Err(e) - } - } - } else { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Console".into(), - message: if trimmed == "help" { - "Commands: status, tasks, ping, user add , user remove , user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components" - .into() - } else { - format!("Unknown command: {}", line) - }, - is_error: false, - }); - Ok(()) - } - } - - async fn send(&self, message: ClientMessage) -> Result<()> { - let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; - let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock()) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out acquiring IPC writer", - ) - })?; - let active = writer_guard.as_mut().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotConnected, - "IPC connection is not active", - ) - })?; - let generation = active.generation; - let write_result = - tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message)) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Timed out writing IPC message", - ) - }) - .and_then(|result| result); - drop(writer_guard); - if write_result.is_err() { - self.mark_disconnected(generation).await; - } - write_result - } - - async fn apply(&self, message: DaemonMessage) { - match message { - DaemonMessage::LogEntry(entry) => { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: entry.timestamp_ms, - sender: entry.sender, - message: entry.message, - is_error: entry.is_error, - }); - } - DaemonMessage::StateUpdate(snapshot) => { - // Never hold a watch borrow while sending to that same - // channel: send waits for outstanding Ref guards. - self.daemon_status.send_modify(|status| { - status.startup_phase = Some(snapshot.startup_phase); - status.degraded_reason = snapshot.degraded_reason.clone(); - status.lifecycle = Some(snapshot.lifecycle); - status.health = snapshot.overall_health; - status.components = snapshot.components.clone(); - }); - let mut state = self.state.app.lock().await; - state.cpu = snapshot.cpu; - state.ram = snapshot.ram; - state.ping = snapshot.ping; - state.net_up = snapshot.net_up; - state.net_down = snapshot.net_down; - state.sys_info = snapshot.sys_info; - } - DaemonMessage::MetricSample(sample) => { - let mut state = self.state.app.lock().await; - if let Some(cpu) = sample.cpu { - state.push_cpu((0.0, cpu)); - } - if let Some(ram) = sample.ram { - state.push_ram((0.0, ram)); - } - if let Some(ping) = sample.ping { - state.push_ping_val(ping); - } - if let Some(net_up) = sample.net_up { - state.push_net_up((0.0, net_up)); - } - if let Some(net_down) = sample.net_down { - state.push_net_down((0.0, net_down)); - } - } - DaemonMessage::Response(response) => { - let mut pending = self.pending.lock().await; - if let Some(request) = pending.remove(&response.request_id) { - let _ = request.response_tx.send(response.result); - } else { - let mut state = self.state.app.lock().await; - let message = match &response.result { - ResponseResult::Ok(payload) => Self::format_payload(payload), - ResponseResult::Error(code) => Self::format_error(code).into(), - }; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Command".into(), - message, - is_error: matches!(&response.result, ResponseResult::Error(_)), - }); - } - } - DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack), - DaemonMessage::Subscribed => {} - DaemonMessage::Pong { .. } => {} - DaemonMessage::LifecycleEvent(event) => match event { - iota_ipc::LifecycleEvent::Shutdown { reason } => { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "Daemon".into(), - message: format!("Daemon shutting down: {}", reason), - is_error: true, - }); - } - iota_ipc::LifecycleEvent::StateChanged(status) => { - self.daemon_status.send_modify(|daemon_status| { - daemon_status.degraded_reason = match status { - iota_ipc::ConnectionStatus::Degraded => { - Some("A daemon dependency is degraded".into()) - } - _ => None, - }; - }); - } - }, - DaemonMessage::Gap { skipped } => { - let mut state = self.state.app.lock().await; - state.push_log(UiLogEntry { - timestamp_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - sender: "System".into(), - message: format!("Skipped {} messages, resynchronizing", skipped), - is_error: false, - }); - } - } - } -} diff --git a/iota-cli/src/layout/fit.rs b/iota-cli/src/layout/fit.rs deleted file mode 100644 index 3908272..0000000 --- a/iota-cli/src/layout/fit.rs +++ /dev/null @@ -1,52 +0,0 @@ -use ratatui::layout::Rect; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RequiredSize { - pub width: u16, - pub height: u16, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FitLevel { - Preferred, - Compact, - Fallback, -} -pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel { - if area.width >= preferred.width && area.height >= preferred.height { - FitLevel::Preferred - } else if area.width >= compact.width && area.height >= compact.height { - FitLevel::Compact - } else { - FitLevel::Fallback - } -} -pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect { - let width = area.width.min(maximum.width); - let height = area.height.min(maximum.height); - Rect { - x: area.x.saturating_add(area.width.saturating_sub(width) / 2), - y: area - .y - .saturating_add(area.height.saturating_sub(height) / 2), - width, - height, - } -} -pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option { - let height = area.height.checked_sub(top)?.checked_sub(bottom)?; - Some(Rect { - x: area.x, - y: area.y.checked_add(top)?, - width: area.width, - height, - }) -} -pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option { - let width = area.width.checked_sub(horizontal.checked_mul(2)?)?; - let height = area.height.checked_sub(vertical.checked_mul(2)?)?; - Some(Rect { - x: area.x.checked_add(horizontal)?, - y: area.y.checked_add(vertical)?, - width, - height, - }) -} diff --git a/iota-cli/src/layout/mod.rs b/iota-cli/src/layout/mod.rs deleted file mode 100644 index f629920..0000000 --- a/iota-cli/src/layout/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod fit; -pub mod text_measure; diff --git a/iota-cli/src/layout/text_measure.rs b/iota-cli/src/layout/text_measure.rs deleted file mode 100644 index 0ba471e..0000000 --- a/iota-cli/src/layout/text_measure.rs +++ /dev/null @@ -1,10 +0,0 @@ -use unicode_width::UnicodeWidthStr; -pub fn wrapped_line_count(text: &str, width: u16) -> u16 { - if width == 0 { - return 0; - } - text.split('\n') - .map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize) - .sum::() - .min(u16::MAX as usize) as u16 -} diff --git a/iota-cli/src/notification.rs b/iota-cli/src/notification.rs deleted file mode 100644 index 0fd3cb7..0000000 --- a/iota-cli/src/notification.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::time::{Duration, Instant}; - -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::theme::ResolvedTheme; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotificationKind { - Success, - Warning, - Error, - Info, -} - -#[derive(Clone)] -pub struct Notification { - pub message: String, - pub kind: NotificationKind, - pub created_at: Instant, - pub duration: Duration, -} - -impl Notification { - pub fn success(message: impl Into) -> Self { - Self::new(message, NotificationKind::Success, Duration::from_secs(3)) - } - - pub fn warning(message: impl Into) -> Self { - Self::new(message, NotificationKind::Warning, Duration::from_secs(4)) - } - - pub fn error(message: impl Into) -> Self { - Self::new(message, NotificationKind::Error, Duration::from_secs(5)) - } - - pub fn info(message: impl Into) -> Self { - Self::new(message, NotificationKind::Info, Duration::from_secs(3)) - } - - fn new(message: impl Into, kind: NotificationKind, duration: Duration) -> Self { - Self { - message: message.into(), - kind, - created_at: Instant::now(), - duration, - } - } - - pub fn is_expired(&self) -> bool { - self.created_at.elapsed() >= self.duration - } - - pub fn remaining(&self) -> Duration { - self.duration.saturating_sub(self.created_at.elapsed()) - } - - pub fn progress(&self) -> f64 { - let elapsed = self.created_at.elapsed().as_secs_f64(); - let total = self.duration.as_secs_f64(); - (elapsed / total).min(1.0) - } -} - -pub fn render_notification( - frame: &mut Frame, - area: Rect, - notification: &Notification, - theme: &ResolvedTheme, -) { - let (prefix, style) = match notification.kind { - NotificationKind::Success => ("✓ ", theme.status.success), - NotificationKind::Warning => ("⚠ ", theme.status.warning), - NotificationKind::Error => ("✗ ", theme.status.error), - NotificationKind::Info => ("ℹ ", theme.status.info), - }; - - let remaining = notification.remaining().as_secs(); - let progress = notification.progress(); - - let mut spans = vec![ - Span::styled(prefix, style), - Span::styled(¬ification.message, theme.text.normal), - ]; - - if remaining > 0 { - let bar_width = 10; - let filled = ((1.0 - progress) * bar_width as f64) as usize; - let empty = bar_width - filled; - let bar: String = "█".repeat(filled) + &"░".repeat(empty); - spans.push(Span::styled( - format!(" [{bar}] {remaining}s"), - theme.text.muted, - )); - } - - let paragraph = Paragraph::new(Line::from(spans)); - frame.render_widget(paragraph, area); -} - -pub fn render_notification_area( - frame: &mut Frame, - area: Rect, - notifications: &[Notification], - theme: &ResolvedTheme, -) { - if notifications.is_empty() { - return; - } - - let visible_height = area.height as usize; - let start = notifications.len().saturating_sub(visible_height); - let visible = ¬ifications[start..]; - - for (i, notification) in visible.iter().enumerate() { - let row = Rect { - x: area.x, - y: area.y + i as u16, - width: area.width, - height: 1, - }; - render_notification(frame, row, notification, theme); - } -} diff --git a/iota-cli/src/render_context.rs b/iota-cli/src/render_context.rs deleted file mode 100644 index 8b7d3c9..0000000 --- a/iota-cli/src/render_context.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::theme::ResolvedTheme; - -/// Immutable state shared by every component during one render pass. -pub struct RenderContext<'a> { - pub theme: &'a ResolvedTheme, -} diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs deleted file mode 100644 index b9cae99..0000000 --- a/iota-cli/src/screens/daemon_setup.rs +++ /dev/null @@ -1,302 +0,0 @@ -use crate::{ - controls::{ - button::{ActionButton, ButtonIntent, render_button}, - choice::{ChoiceKind, render_choice_line}, - radio_group::{RadioGroup, RadioItem}, - }, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, -}; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Text}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::any::Any; -use tokio::sync::oneshot; - -/// Kept on screen while the launcher waits for the daemon's IPC hello. The -/// setup choice screen is intentionally closed before its decision is sent, -/// so without this the terminal would otherwise be blank during startup. -pub struct DaemonStartingScreen; -impl Screen for DaemonStartingScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - _hits: &mut HitMap, - ) { - let popup = crate::layout::fit::centered_rect( - area, - crate::layout::fit::RequiredSize { - width: 48, - height: 5, - }, - ); - frame.render_widget( - Paragraph::new( - "Starting iota-daemon…\nWaiting for its IPC handshake.\nPress Ctrl+C to cancel.", - ) - .wrap(Wrap { trim: true }) - .block( - Block::default() - .title(" Iota daemon ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal), - ), - popup, - ); - } - fn handle_event(&mut self, _: UiEvent) -> InteractionResult { - InteractionResult::Handled - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DaemonLaunchMode { - Once, - WithUi, - WithSystem, -} -#[derive(Debug, Clone)] -pub struct LaunchOption { - pub mode: DaemonLaunchMode, - pub enabled: bool, - pub reason: Option, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DaemonSetupDecision { - Start(DaemonLaunchMode), - Exit, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Focus { - Options, - Exit, - Action, -} - -/// The launcher owns the actual side effects. This screen only presents the -/// capabilities discovered for this machine, keeping disabled choices visible. -pub struct DaemonSetupScreen { - choices: RadioGroup, - focus: Focus, - sender: Option>, - message: String, -} -impl DaemonSetupScreen { - pub fn new( - options: Vec, - message: impl Into, - sender: oneshot::Sender, - ) -> Result { - let items: Vec> = options - .into_iter() - .map(|o| RadioItem { - value: o.mode, - label: match o.mode { - DaemonLaunchMode::Once => "Start once", - DaemonLaunchMode::WithUi => "Start with Iota UI", - DaemonLaunchMode::WithSystem => "Start with the system", - } - .into(), - description: o.reason, - enabled: o.enabled, - disabled_reason: None, - }) - .collect(); - let default = items - .iter() - .find(|item| item.enabled) - .map(|item| item.value) - .ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?; - let mut choices = RadioGroup::new(items, None, default)?; - choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include); - Ok(Self { - choices, - focus: Focus::Options, - sender: Some(sender), - message: message.into(), - }) - } - fn complete(&mut self, d: DaemonSetupDecision) { - if let Some(tx) = self.sender.take() { - let _ = tx.send(d); - } - } - fn activate(&mut self) -> InteractionResult { - match self.focus { - Focus::Options => { - self.choices.select_focused(); - InteractionResult::Handled - } - Focus::Exit => { - self.complete(DaemonSetupDecision::Exit); - InteractionResult::CloseScreen - } - Focus::Action => { - let choice = *self.choices.selected(); - if self - .choices - .items() - .iter() - .find(|i| i.value == choice) - .is_some_and(|i| i.enabled) - { - self.complete(DaemonSetupDecision::Start(choice)); - InteractionResult::CloseScreen - } else { - InteractionResult::Handled - } - } - } - } - fn next(&mut self) { - self.focus = match self.focus { - Focus::Options => { - self.choices.focus_next(); - if self.choices.focused_item().value == DaemonLaunchMode::Once { - Focus::Exit - } else { - Focus::Options - } - } - Focus::Exit => Focus::Action, - Focus::Action => Focus::Options, - }; - } - fn previous(&mut self) { - self.focus = match self.focus { - Focus::Options => { - self.choices.focus_previous(); - if self.choices.focused_item().value == DaemonLaunchMode::WithSystem { - Focus::Action - } else { - Focus::Options - } - } - Focus::Exit => Focus::Options, - Focus::Action => Focus::Exit, - }; - } -} -impl Screen for DaemonSetupScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - _hits: &mut HitMap, - ) { - let popup = crate::layout::fit::centered_rect( - area, - crate::layout::fit::RequiredSize { - width: 68, - height: 16, - }, - ); - let mut lines = vec![Line::from(self.message.as_str()), Line::from("")]; - for item in self.choices.items() { - lines.push(render_choice_line( - &item.label, - ChoiceKind::Radio, - self.choices.visual_state(&item.value), - context.theme, - )); - if let Some(reason) = &item.description { - lines.push(Line::styled( - format!(" {reason}"), - context.theme.text.muted, - )); - } - } - let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup); - frame.render_widget( - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: true }) - .block( - Block::default() - .title(" Iota daemon setup ") - .borders(Borders::ALL), - ), - rows[0], - ); - let b = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(rows[1]); - render_button( - frame, - b[0], - ActionButton { - label: "Exit", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Exit, - enabled: true, - }, - context.theme, - ); - let selected = *self.choices.selected(); - let enabled = self - .choices - .items() - .iter() - .find(|i| i.value == selected) - .is_some_and(|i| i.enabled); - let label = match selected { - DaemonLaunchMode::Once => "Start once", - DaemonLaunchMode::WithUi => "Save and start", - DaemonLaunchMode::WithSystem => "Configure and start", - }; - render_button( - frame, - b[1], - ActionButton { - label, - intent: if enabled { - ButtonIntent::Primary - } else { - ButtonIntent::Destructive - }, - focused: self.focus == Focus::Action, - enabled, - }, - context.theme, - ); - } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; - match event.code { - KeyCode::Esc => { - self.complete(DaemonSetupDecision::Exit); - InteractionResult::CloseScreen - } - KeyCode::Down | KeyCode::Right | KeyCode::Tab => { - self.next(); - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Left | KeyCode::BackTab => { - self.previous(); - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } -} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs deleted file mode 100644 index 53e68b3..0000000 --- a/iota-cli/src/screens/main_screen.rs +++ /dev/null @@ -1,515 +0,0 @@ -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - elements::{ - console_card::ConsoleCard, - elements::{InteractableElement, JoinableElement}, - graph_card::{GRAPHS, GraphCard}, - log_card::LogCard, - }, - interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcConnectionState}, - render_context::RenderContext, - screens::{ - overview::OverviewScreen, - screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent}, - }, - ui::UI, -}; - -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - widgets::Borders, -}; -use tokio::sync::watch; - -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, -}; - -pub struct MainScreen { - elements: Vec>, - nav_grid: Vec>>, - selected_coords: (usize, usize), - graphs_open: bool, - connection_status_rx: watch::Receiver, - daemon_status_rx: watch::Receiver, - layout_width: AtomicU16, -} - -impl MainScreen { - pub fn connection_status(&self) -> watch::Receiver { - self.connection_status_rx.clone() - } - pub fn daemon_status(&self) -> watch::Receiver { - self.daemon_status_rx.clone() - } - pub async fn new(ui: Arc) -> Self { - let mut elements: Vec> = Vec::new(); - - let nav_grid = vec![ - vec![Some(0), Some(2)], - vec![Some(0), Some(3)], - vec![Some(1), Some(4)], - ]; - - let state = ui - .client_state() - .await - .expect("MainScreen requires an attached daemon"); - let mut log_card = LogCard::new(state.clone()); - log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); - let ipc = ui - .ipc() - .await - .expect("MainScreen requires an attached daemon"); - let mut console_card = ConsoleCard::new("Console", "", ipc.clone()); - console_card.set_joins(Borders::TOP); - - elements.push(Box::new(log_card)); - elements.push(Box::new(console_card)); - - let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()); - ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); - elements.push(Box::new(ram_graph)); - let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()); - cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); - cpu_graph.set_joins(Borders::TOP); - elements.push(Box::new(cpu_graph)); - let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into()); - ping_graph.set_joins(Borders::TOP); - elements.push(Box::new(ping_graph)); - - let graphs_open = true; - - let connection_status_rx = ipc.connection_status(); - let daemon_status_rx = ipc.daemon_status(); - - let mut screen = MainScreen { - elements, - nav_grid, - selected_coords: (1, 0), - graphs_open, - connection_status_rx, - daemon_status_rx, - layout_width: AtomicU16::new(0), - }; - screen.focus_current(); - screen - } - - fn focus_current(&mut self) { - let (y, x) = self.selected_coords; - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { - if let Some(element) = self.elements.get_mut(*index) { - if element.can_focus() { - element.focus(true); - } - } - } - } - - fn unfocus_current(&mut self, y: usize, x: usize) { - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { - if let Some(element) = self.elements.get_mut(*index) { - element.focus(false); - } - } - } - - fn navigate(&mut self, direction: NavDirection) { - let (current_row, current_col) = self.selected_coords; - let current_element = self.nav_grid[current_row][current_col]; - - self.unfocus_current(current_row, current_col); - - let (delta_row, delta_col) = match direction { - NavDirection::Up => (-1isize, 0), - NavDirection::Down => (1, 0), - NavDirection::Left => (0, -1), - NavDirection::Right => (0, 1), - _ => (0, 0), - }; - - let mut next_row = current_row as isize; - let mut next_col = current_col as isize; - - loop { - next_row += delta_row; - next_col += delta_col; - - if next_row < 0 || next_col < 0 { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - let next_row_u = next_row as usize; - let next_col_u = next_col as usize; - - if next_row_u >= self.nav_grid.len() { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - - if let Some(row) = self.nav_grid.get(next_row_u) { - if next_col_u >= row.len() { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - - if let Some(next_element) = row[next_col_u] { - if Some(next_element) != current_element { - self.selected_coords = (next_row_u, next_col_u); - self.focus_current(); - return; - } - } - } - } - - self.focus_current(); - } - - /// Cycle focus between unique elements in the navigation grid. - fn navigate_focus(&mut self, forward: bool) { - // Collect unique elements in grid order. - let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col) - let mut seen: Vec> = Vec::new(); - for (y, row) in self.nav_grid.iter().enumerate() { - for (x, elem_opt) in row.iter().enumerate() { - if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) { - continue; - } - if elem_opt.is_some() && !seen.contains(elem_opt) { - seen.push(*elem_opt); - positions.push((y, x)); - } - } - } - - let current = self.selected_coords; - let current_pos = positions - .iter() - .position(|&(r, c)| r == current.0 && c == current.1); - - let next_pos = if let Some(idx) = current_pos { - if forward { - (idx + 1) % positions.len() - } else { - (idx + positions.len() - 1) % positions.len() - } - } else { - 0 - }; - - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = positions[next_pos]; - self.focus_current(); - } -} - -impl Screen for MainScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { - self.layout_width.store(rect.width, Ordering::Relaxed); - f.render_widget( - ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), - rect, - ); - let inner = rect; - - let metrics_visible = self.graphs_open && inner.width >= 70; - let graphs_width = if metrics_visible { 30 } else { 0 }; - let main_width = inner.width.saturating_sub(graphs_width); - - let horizontal_chunks = Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Length(main_width), - Constraint::Length(graphs_width), - ]) - .split(inner); - - let left_area = horizontal_chunks[0]; - let right_area = horizontal_chunks[1]; - hits.register(left_area, AppAction::FocusLogs); - if metrics_visible { - hits.register(right_area, AppAction::FocusMetrics); - } - - if inner.width >= 70 { - let metrics_button = Rect { - x: right_area.x, - y: right_area.y, - width: right_area.width, - height: 1, - }; - render_button( - f, - metrics_button, - ActionButton { - label: if self.graphs_open { - "Hide metrics" - } else { - "Show metrics" - }, - intent: ButtonIntent::Neutral, - focused: false, - enabled: true, - }, - context.theme, - ); - hits.register(metrics_button, AppAction::ToggleMetrics); - } - - let left_rows = - Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); - hits.register(left_rows[1], AppAction::FocusConsole); - - if let Some(log) = self.elements.get(0) { - log.as_element().render(f, left_rows[0], context); - } - - if let Some(console) = self.elements.get(1) { - console.as_element().render(f, left_rows[1], context); - } - - let graph_elements: Vec<_> = self - .elements - .iter() - .filter(|el| el.as_any().is::()) - .collect(); - - if metrics_visible && !graph_elements.is_empty() { - let graph_chunks = Layout::vertical( - graph_elements - .iter() - .map(|_| Constraint::Ratio(1, graph_elements.len() as u32)) - .collect::>(), - ) - .split(right_area); - - for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) { - el.as_element().render(f, *area, context); - } - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - if let UiEvent::Paste(text) = &event { - if self.selected_coords == (2, 0) { - if let Some(console) = self - .elements - .get_mut(1) - .and_then(|element| element.as_any_mut().downcast_mut::()) - { - console.handle_paste(text); - return InteractionResult::Handled; - } - } - return InteractionResult::Unhandled; - } - if let UiEvent::Resize(width, _) = &event { - self.layout_width.store(*width, Ordering::Relaxed); - if *width < 70 && self.selected_coords.1 == 1 { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); - self.focus_current(); - } - return InteractionResult::Handled; - } - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; - // A focused console consumes text and cursor keys before dashboard - // shortcuts; commands such as `users` must remain typeable. - if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) - { - if let Some(console) = self.elements.get_mut(1) { - return console.interact(event); - } - } - match event.code { - KeyCode::Tab => { - self.navigate_focus(true); - return InteractionResult::Handled; - } - KeyCode::BackTab => { - self.navigate_focus(false); - return InteractionResult::Handled; - } - KeyCode::Char('o') | KeyCode::Char('O') => { - let conn_rx = self.connection_status_rx.clone(); - let daemon_rx = self.daemon_status_rx.clone(); - return InteractionResult::OpenScreen { - screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)), - }; - } - KeyCode::Char('u') | KeyCode::Char('U') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenUsers) - }), - }; - } - KeyCode::Char('m') | KeyCode::Char('M') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics) - }), - }; - } - KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { - self.graphs_open = !self.graphs_open; - for element in self.elements.iter_mut() { - if let Some(graph) = element.as_any_mut().downcast_mut::() { - graph.set_open(self.graphs_open); - } - } - return InteractionResult::Handled; - } - _ => { - let (y, x) = self.selected_coords; - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) { - if let Some(el) = self.elements.get_mut(*index) { - let result = el.interact(event); - if matches!(result, InteractionResult::Unhandled) { - match event.code { - KeyCode::Up => self.navigate(NavDirection::Up), - KeyCode::Down => self.navigate(NavDirection::Down), - KeyCode::Left => self.navigate(NavDirection::Left), - KeyCode::Right => self.navigate(NavDirection::Right), - _ => {} - } - } - return result; - } - } - } - } - - InteractionResult::Handled - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::ToggleMetrics => { - self.graphs_open = !self.graphs_open; - for element in &mut self.elements { - if let Some(graph) = element.as_any_mut().downcast_mut::() { - graph.set_open(self.graphs_open); - } - } - InteractionResult::Handled - } - AppAction::OpenOverview => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o')))) - } - AppAction::OpenUsers => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u')))) - } - AppAction::FocusLogs => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); - self.focus_current(); - InteractionResult::Handled - } - AppAction::FocusConsole => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (2, 0); - self.focus_current(); - InteractionResult::Handled - } - AppAction::FocusMetrics => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 1); - self.focus_current(); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn key_hints(&self) -> Vec { - if self.selected_coords == (2, 0) { - vec![ - KeyHint { - keys: "Enter", - action: "Send", - }, - KeyHint { - keys: "Up/Down", - action: "History", - }, - KeyHint { - keys: "Tab", - action: "Complete", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } else if self.selected_coords == (0, 0) { - vec![ - KeyHint { - keys: "J/K", - action: "Scroll logs", - }, - KeyHint { - keys: "Enter", - action: "Lock scroll", - }, - KeyHint { - keys: "/", - action: "Filter", - }, - KeyHint { - keys: "M", - action: "Metrics screen", - }, - KeyHint { - keys: "Tab", - action: "Next panel", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } else { - vec![ - KeyHint { - keys: "Enter", - action: "Toggle metrics", - }, - KeyHint { - keys: "Tab", - action: "Next panel", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } - } -} diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs deleted file mode 100644 index 1243979..0000000 --- a/iota-cli/src/screens/metrics.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::any::Any; - -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::{ - elements::{ - elements::Element, - graph_card::{GRAPHS, GraphCard}, - }, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, - ui::UI, -}; - -const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")]; - -pub struct MetricsScreen { - graphs: Vec, - range_index: usize, -} - -impl MetricsScreen { - pub async fn new(ui: std::sync::Arc) -> Option { - let state = ui.client_state().await?; - let mut screen = Self { - graphs: vec![ - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()), - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()), - GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()), - ], - range_index: 0, - }; - screen.apply_range(); - Some(screen) - } - - fn apply_range(&mut self) { - let width = RANGES[self.range_index].0; - for graph in &mut self.graphs { - graph.set_sample_width(width); - } - } - - fn change_range(&mut self, delta: isize) { - self.range_index = - (self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize; - self.apply_range(); - } -} - -impl Screen for MetricsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - hits: &mut HitMap, - ) { - let block = Block::default() - .title(" Metrics ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal); - let inner = block.inner(area); - frame.render_widget(block, area); - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ]) - .split(inner); - frame.render_widget( - Paragraph::new(format!( - "Range: {} ({} samples) Left/Right to change", - RANGES[self.range_index].1, RANGES[self.range_index].0 - )) - .style(context.theme.text.heading), - rows[0], - ); - for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) { - graph.render(frame, *graph_area, context); - } - hits.register(rows[0], AppAction::OpenMetrics); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Left => { - self.change_range(-1); - InteractionResult::Handled - } - KeyCode::Right => { - self.change_range(1); - InteractionResult::Handled - } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Left/Right", - action: "Range", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } -} diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs deleted file mode 100644 index 6f32393..0000000 --- a/iota-cli/src/screens/overview.rs +++ /dev/null @@ -1,302 +0,0 @@ -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcConnectionState}, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, -}; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::{ - any::Any, - sync::atomic::{AtomicUsize, Ordering}, -}; -use tokio::sync::watch; - -pub struct OverviewScreen { - connection_rx: watch::Receiver, - daemon_rx: watch::Receiver, - _focus: Focus, - scroll_offset: usize, - content_height: AtomicUsize, - viewport_height: AtomicUsize, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - Back, -} - -impl OverviewScreen { - pub fn new( - connection_rx: watch::Receiver, - daemon_rx: watch::Receiver, - ) -> Self { - Self { - connection_rx, - daemon_rx, - _focus: Focus::Back, - scroll_offset: 0, - content_height: AtomicUsize::new(0), - viewport_height: AtomicUsize::new(1), - } - } - - fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { - let conn = self.connection_rx.borrow().clone(); - let daemon = self.daemon_rx.borrow().clone(); - - let mut lines = Vec::new(); - - lines.push(Line::from(Span::styled("Connection", theme.text.heading))); - lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); - let omikron = daemon.components.get(&iota_ipc::ComponentId::Omikron); - let omikron_label = match omikron.map(|health| health.status) { - Some(iota_ipc::HealthStatus::Healthy) => "[OK] Connected", - Some(iota_ipc::HealthStatus::Degraded) => "[WARN] Connecting or unavailable", - Some(iota_ipc::HealthStatus::Failed) => "[FAIL] Authentication failed", - None => "Unknown", - }; - lines.push(Line::from(format!(" Omikron: {omikron_label}"))); - if let Some(message) = omikron.and_then(|health| health.message.as_deref()) { - lines.push(Line::from(format!(" Omikron detail: {message}"))); - } - lines.push(Line::from("")); - - lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); - lines.push(Line::from(format!( - " Version: {}", - version_or_unknown(&daemon.version) - ))); - lines.push(Line::from(format!( - " Instance: {}", - truncate_id(&daemon.instance_id) - ))); - - let phase = daemon - .startup_phase - .map(|p| format!("{:?}", p)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Phase: {}", phase))); - - let lifecycle = daemon - .lifecycle - .map(|l| format!("{:?}", l)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Lifecycle: {}", lifecycle))); - - let health = match daemon.health { - iota_ipc::HealthStatus::Healthy => "[OK] Healthy", - iota_ipc::HealthStatus::Degraded => "[WARN] Degraded", - iota_ipc::HealthStatus::Failed => "[FAIL] Failed", - }; - lines.push(Line::from(format!(" Health: {health}"))); - - if let Some(ref reason) = daemon.degraded_reason { - lines.push(Line::from(Span::styled( - format!(" Degraded: {reason}"), - theme.status.warning, - ))); - } - - let mode = daemon - .deployment_mode - .map(|m| format!("{:?}", m)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Deployment: {mode}"))); - - let supervisor = daemon - .supervisor - .map(|s| format!("{:?}", s)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Supervisor: {supervisor}"))); - - if !daemon.components.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled("Components", theme.text.heading))); - for (id, health) in &daemon.components { - let status_str = match health.status { - iota_ipc::HealthStatus::Healthy => "[OK] healthy", - iota_ipc::HealthStatus::Degraded => "[WARN] degraded", - iota_ipc::HealthStatus::Failed => "[FAIL] failed", - }; - let suffix = health - .message - .as_deref() - .map(|m| format!(" ({m})")) - .unwrap_or_default(); - lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix))); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Press Esc or B to return to the dashboard", - theme.text.muted, - ))); - - lines - } -} - -fn connection_label(conn: &IpcConnectionState) -> String { - match conn { - IpcConnectionState::Connected => "Connected".into(), - IpcConnectionState::Connecting => "Connecting...".into(), - IpcConnectionState::Reconnecting { attempt } => { - format!("Reconnecting (attempt {attempt})...") - } - IpcConnectionState::Incompatible { message } => { - format!("Incompatible: {message}") - } - IpcConnectionState::Failed { message } => format!("Failed: {message}"), - IpcConnectionState::Disconnected => "Disconnected".into(), - } -} - -fn version_or_unknown(v: &str) -> String { - if v.is_empty() { - "Unknown".into() - } else { - v.into() - } -} - -fn truncate_id(id: &str) -> String { - if id.len() > 8 { - format!("{}…", &id[..8]) - } else { - id.into() - } -} - -impl Screen for OverviewScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let block = Block::default() - .title(" Overview ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) - } else { - let inner = block.inner(rect); - f.render_widget(block, rect); - inner - }; - let rows = ratatui::layout::Layout::vertical([ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(1), - ]) - .split(inner); - - let lines = self.build_lines(context.theme); - self.content_height.store(lines.len(), Ordering::Relaxed); - self.viewport_height - .store(rows[0].height as usize, Ordering::Relaxed); - let par = Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .scroll((self.scroll_offset as u16, 0)); - f.render_widget(par, rows[0]); - render_button( - f, - rows[1], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self._focus == Focus::Back, - enabled: true, - }, - context.theme, - ); - _hits.register(rows[1], AppAction::Back); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; - match event.code { - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); - self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - self.scroll_offset = self.scroll_offset.saturating_sub(1); - InteractionResult::Handled - } - KeyCode::PageDown => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - let max = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(page); - self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); - InteractionResult::Handled - } - KeyCode::PageUp => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.scroll_offset = self.scroll_offset.saturating_sub(page); - InteractionResult::Handled - } - KeyCode::Home => { - self.scroll_offset = 0; - InteractionResult::Handled - } - KeyCode::End => { - self.scroll_offset = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - if action == AppAction::Back { - InteractionResult::CloseScreen - } else { - InteractionResult::Unhandled - } - } - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Up/Down", - action: "Scroll", - }, - KeyHint { - keys: "PgUp/PgDn", - action: "Page", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } -} diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs deleted file mode 100644 index 5299b3b..0000000 --- a/iota-cli/src/screens/screens.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::any::Any; - -use crossterm::event::{KeyEvent, MouseEvent}; -use ratatui::{Frame, layout::Rect}; - -use crate::{interaction_result::InteractionResult, render_context::RenderContext}; - -/// All terminal input that can affect the UI. Keeping this as one type makes -/// it impossible for screens to accidentally ignore a newly supported event. -#[derive(Debug, Clone)] -pub enum UiEvent { - Key(KeyEvent), - Mouse(MouseEvent), - Paste(String), - Resize(u16, u16), - App(AppEvent), -} - -/// Completion of background UI work. Keeping it in the regular event stream -/// gives screens an explicit success/failure path instead of detached tasks. -#[derive(Debug, Clone)] -pub enum AppEvent { - OpenUsers, - OpenMetrics, - ApplyTheme { - theme: crate::theme::ThemeName, - persist: bool, - }, - SaveSettings { - theme: crate::theme::ThemeName, - color: crate::theme::TerminalPolicy, - unicode: crate::theme::TerminalPolicy, - cli_output: crate::theme::CliOutputFormat, - cli_require_confirmation: bool, - }, - ThemeSaved(Result<(), String>), - UsersLoaded(Result, String>), - UserCreated(Result), - UserRemoved { - user_id: i64, - result: Result<(), String>, - }, - RegenerateKeysRequested, - KeysRegenerated(Result<(), String>), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AppAction { - OpenOverview, - OpenUsers, - OpenSettings, - OpenMetrics, - ToggleMetrics, - AddUser, - RemoveUser, - Back, - Quit, - FocusLogs, - FocusConsole, - FocusMetrics, - OpenMain, - SelectUser(usize), - ConfirmDialog, - CancelDialog, - RegenerateKeys, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeyHint { - pub keys: &'static str, - pub action: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HitRegion { - pub area: Rect, - pub action: AppAction, -} - -#[derive(Debug, Default, Clone)] -pub struct HitMap { - regions: Vec, -} - -impl HitMap { - pub fn register(&mut self, area: Rect, action: AppAction) { - self.regions.push(HitRegion { area, action }); - } - pub fn action_at(&self, column: u16, row: u16) -> Option { - self.regions - .iter() - .rev() - .find(|region| { - column >= region.area.x - && column < region.area.x.saturating_add(region.area.width) - && row >= region.area.y - && row < region.area.y.saturating_add(region.area.height) - }) - .map(|region| region.action) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NavDirection { - Up, - Down, - Left, - Right, - - Next, - Prev, -} - -pub trait Screen: Send + Sync + Any { - fn as_any(&self) -> &dyn Any; - fn as_any_mut(&mut self) -> &mut dyn Any; - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap); - fn handle_event(&mut self, event: UiEvent) -> InteractionResult; - fn handle_action(&mut self, _action: AppAction) -> InteractionResult { - InteractionResult::Unhandled - } - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "Enter", - action: "Activate", - }, - KeyHint { - keys: "Esc", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } -} diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs deleted file mode 100644 index a0a37d8..0000000 --- a/iota-cli/src/screens/settings.rs +++ /dev/null @@ -1,475 +0,0 @@ -use std::any::Any; - -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, - theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig}, -}; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - Theme, - CliOutput, - CliConfirm, - RegenerateKeys, - Back, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Dialog { - ConfirmRegenerateKeys, -} - -pub struct SettingsScreen { - selected: usize, - saved: ThemeName, - message: String, - color: TerminalPolicy, - unicode: TerminalPolicy, - cli_output: CliOutputFormat, - cli_require_confirmation: bool, - focus: Focus, - dialog: Option, - pending: bool, -} - -impl SettingsScreen { - pub fn new(current: ThemeName) -> Self { - let selected = ThemeName::ALL - .iter() - .position(|theme| *theme == current) - .unwrap_or(0); - let config = UiConfig::load_or_default(); - Self { - selected, - saved: current, - message: "Left/Right previews. Enter saves.".into(), - color: config.color, - unicode: config.unicode, - cli_output: config.cli_output, - cli_require_confirmation: config.cli_require_confirmation, - focus: Focus::Theme, - dialog: None, - pending: false, - } - } - - fn selected_theme(&self) -> ThemeName { - ThemeName::ALL[self.selected] - } - - fn apply(&self, persist: bool) -> InteractionResult { - let theme = self.selected_theme(); - InteractionResult::AppTask { - task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }), - } - } - - fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { - match policy { - TerminalPolicy::Auto => TerminalPolicy::Always, - TerminalPolicy::Always => TerminalPolicy::Never, - TerminalPolicy::Never => TerminalPolicy::Auto, - } - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::Theme => Focus::CliOutput, - Focus::CliOutput => Focus::CliConfirm, - Focus::CliConfirm => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::Back, - Focus::Back => Focus::Theme, - }; - } - - fn prev_focus(&mut self) { - self.focus = match self.focus { - Focus::Theme => Focus::Back, - Focus::Back => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::CliConfirm, - Focus::CliConfirm => Focus::CliOutput, - Focus::CliOutput => Focus::Theme, - }; - } - - fn activate(&mut self) -> InteractionResult { - if self.pending { - return InteractionResult::Handled; - } - if let Some(dialog) = self.dialog.take() { - match dialog { - Dialog::ConfirmRegenerateKeys => { - self.pending = true; - self.message = "Regenerating keys…".into(); - return InteractionResult::AppTask { - task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }), - }; - } - } - } - match self.focus { - Focus::Theme => { - self.message = "Saving theme…".into(); - } - Focus::CliOutput => { - self.message = "Output format updated.".into(); - return InteractionResult::Handled; - } - Focus::CliConfirm => { - self.cli_require_confirmation = !self.cli_require_confirmation; - self.message = format!( - "Confirm: {}", - if self.cli_require_confirmation { - "On" - } else { - "Off" - }, - ); - return InteractionResult::Handled; - } - Focus::RegenerateKeys => { - self.dialog = Some(Dialog::ConfirmRegenerateKeys); - return InteractionResult::Handled; - } - Focus::Back => return InteractionResult::CloseScreen, - } - let theme = self.selected_theme(); - let color = self.color; - let unicode = self.unicode; - let cli_output = self.cli_output; - let cli_require_confirmation = self.cli_require_confirmation; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - cli_output, - cli_require_confirmation, - }) - }), - } - } -} - -impl Screen for SettingsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - hits: &mut HitMap, - ) { - let header_block = Block::default() - .title(" Settings ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused); - let inner = header_block.inner(area); - frame.render_widget(header_block, area); - - let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); - - frame.render_widget( - Paragraph::new(format!( - "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", - self.selected_theme(), - if self.selected_theme() == self.saved { - " [saved]" - } else { - " [preview]" - }, - self.color, - self.unicode, - )) - .style(context.theme.text.heading), - sections[0], - ); - - let cli_line = format!( - "CLI output: {:?} (L) Confirm: {} (K)", - self.cli_output, - if self.cli_require_confirmation { - "required" - } else { - "disabled" - }, - ); - - let bottom_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]); - - let lines = vec![ - Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(Span::styled(&cli_line, context.theme.text.normal)), - Line::from("Preview"), - Line::from("[OK] Healthy"), - Line::from("[WARN] Degraded"), - Line::from("[FAIL] Failed"), - Line::from("> Focused action <"), - ]; - frame.render_widget( - Paragraph::new(lines).style(context.theme.text.normal), - bottom_rows[0], - ); - - let buttons_area = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(34), - Constraint::Percentage(33), - ]) - .split(bottom_rows[1]); - - render_button( - frame, - buttons_area[0], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Back && self.dialog.is_none(), - enabled: true, - }, - context.theme, - ); - hits.register(buttons_area[0], AppAction::Back); - - render_button( - frame, - buttons_area[1], - ActionButton { - label: "Regenerate Keys", - intent: ButtonIntent::Destructive, - focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(), - enabled: !self.pending, - }, - context.theme, - ); - hits.register(buttons_area[1], AppAction::RegenerateKeys); - - if self.dialog.is_some() { - frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area); - let popup = crate::layout::fit::centered_rect( - area, - crate::layout::fit::RequiredSize { - width: 42, - height: 7, - }, - ); - let block = Block::default() - .title(" Confirm ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - let popup_inner = block.inner(popup); - frame.render_widget(block, popup); - let dialog_rows = - Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); - frame.render_widget( - Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal), - dialog_rows[0], - ); - let dialog_buttons = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(dialog_rows[1]); - render_button( - frame, - dialog_buttons[0], - ActionButton { - label: "Cancel", - intent: ButtonIntent::Cancel, - focused: false, - enabled: true, - }, - context.theme, - ); - render_button( - frame, - dialog_buttons[1], - ActionButton { - label: "Regenerate", - intent: ButtonIntent::Destructive, - focused: true, - enabled: true, - }, - context.theme, - ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let event = match event { - UiEvent::App(AppEvent::ThemeSaved(result)) => { - match result { - Ok(()) => { - self.saved = self.selected_theme(); - self.message = "Theme saved to ui.yaml.".into(); - } - Err(error) => self.message = error, - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::KeysRegenerated(result)) => { - self.pending = false; - self.dialog = None; - match result { - Ok(()) => self.message = "Keys regenerated successfully.".into(), - Err(error) => self.message = error, - } - return InteractionResult::Handled; - } - event => event, - }; - - if self.dialog.is_some() { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - return match key.code { - KeyCode::Esc => { - self.dialog = None; - InteractionResult::Handled - } - KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, - }; - } - - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Left => { - if self.focus == Focus::Theme { - self.selected = self.selected.saturating_sub(1); - self.apply(false) - } else { - InteractionResult::Handled - } - } - KeyCode::Right => { - if self.focus == Focus::Theme { - self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1); - self.apply(false) - } else { - InteractionResult::Handled - } - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - KeyCode::Tab => { - self.next_focus(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_focus(); - InteractionResult::Handled - } - KeyCode::Char('c') | KeyCode::Char('C') => { - self.color = Self::next_policy(self.color); - InteractionResult::Handled - } - KeyCode::Char('u') | KeyCode::Char('U') => { - self.unicode = Self::next_policy(self.unicode); - InteractionResult::Handled - } - KeyCode::Char('l') | KeyCode::Char('L') => { - self.cli_output = self.cli_output.next(); - self.message = format!("CLI output: {:?}", self.cli_output); - InteractionResult::Handled - } - KeyCode::Char('k') | KeyCode::Char('K') => { - self.cli_require_confirmation = !self.cli_require_confirmation; - self.message = format!( - "CLI confirm: {}", - if self.cli_require_confirmation { - "On" - } else { - "Off" - }, - ); - InteractionResult::Handled - } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, - } - } - - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::Back => InteractionResult::CloseScreen, - AppAction::RegenerateKeys => { - self.focus = Focus::RegenerateKeys; - self.activate() - } - AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), - AppAction::CancelDialog if self.dialog.is_some() => { - self.dialog = None; - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - if self.dialog.is_some() { - vec![ - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } else { - vec![ - KeyHint { - keys: "Left/Right", - action: "Preview theme", - }, - KeyHint { - keys: "Enter", - action: "Save/Activate", - }, - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "C/U", - action: "Color/Unicode", - }, - KeyHint { - keys: "L/K", - action: "CLI Out/Confirm", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - ] - } - } -} diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs deleted file mode 100644 index 1ff2689..0000000 --- a/iota-cli/src/screens/users.rs +++ /dev/null @@ -1,741 +0,0 @@ -use crate::{ - controls::{ - button::{ActionButton, ButtonIntent, render_button}, - choice::{ChoiceKind, render_choice_line}, - }, - interaction_result::InteractionResult, - ipc_client::IpcClient, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, -}; -use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU8, AtomicUsize, Ordering}, - }, -}; - -#[derive(Clone, Debug)] -pub struct UserEntry { - pub user_id: i64, - pub username: String, - pub state: iota_ipc::LocalUserState, - pub data_present: bool, - pub credential_present: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - List, - AddButton, - RemoveButton, - Back, -} -#[derive(Clone, Debug)] -enum Dialog { - Add { username: String }, - Remove { user: UserEntry }, -} - -pub struct UsersScreen { - users: Vec, - focused_index: usize, - focus: Focus, - ipc: Arc, - message: Option, - dialog: Option, - pending_dialog: Option, - loading: bool, - pending: bool, - scroll_offset: usize, - viewport_height: AtomicUsize, - filter: String, - filtering: bool, - tick: AtomicU8, -} - -impl UsersScreen { - pub fn new(ipc: Arc, users: Vec) -> Self { - Self { - users, - focused_index: 0, - focus: Focus::List, - ipc, - message: None, - dialog: None, - pending_dialog: None, - loading: false, - pending: false, - scroll_offset: 0, - viewport_height: AtomicUsize::new(1), - filter: String::new(), - filtering: false, - tick: AtomicU8::new(0), - } - } - - pub fn loading(ipc: Arc) -> Self { - let mut screen = Self::new(ipc, Vec::new()); - screen.loading = true; - screen.message = Some("Loading users…".into()); - screen - } - - fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let visible_indices = self.filtered_indices(); - let title = if self.filter.is_empty() { - format!("Users ({})", self.users.len()) - } else { - format!( - "Users ({}/{}) filter: {}", - visible_indices.len(), - self.users.len(), - self.filter - ) - }; - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &title, - self.focus == Focus::List, - context.theme, - ) - } else { - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .border_style(context.theme.borders.normal); - let inner = block.inner(area); - f.render_widget(block, area); - inner - }; - - if self.loading { - const SPINNERS: &[u8] = b"|/-\\"; - let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; - f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner); - return; - } - if visible_indices.is_empty() { - let par = Paragraph::new(if self.users.is_empty() { - "No users found." - } else { - "No users match the filter." - }); - f.render_widget(par, inner); - return; - } - - let mut lines = Vec::new(); - self.viewport_height - .store(inner.height as usize, Ordering::Relaxed); - let labels: Vec<(usize, String)> = visible_indices - .iter() - .skip(self.scroll_offset) - .take(inner.height as usize) - .map(|user_index| { - let user = &self.users[*user_index]; - ( - *user_index, - format!( - "{:>6} {} {}{}", - user.user_id, - user.username, - match user.state { - iota_ipc::LocalUserState::Managed => "managed", - iota_ipc::LocalUserState::Released => "released", - }, - if user.data_present { - "" - } else { - ", data purged" - } - ), - ) - }) - .collect(); - for (user_index, label) in &labels { - let visual = crate::controls::choice::ChoiceVisualState { - selected: false, - focused: self.focus == Focus::List && *user_index == self.focused_index, - enabled: !self.loading && !self.pending, - }; - lines.push(render_choice_line( - &label, - ChoiceKind::Radio, - visual, - context.theme, - )); - } - let par = Paragraph::new(lines); - f.render_widget(par, inner); - } - - fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area); - - if let Some(msg) = &self.message { - let par = Paragraph::new(Line::from(Span::styled( - msg.as_str(), - context.theme.text.muted, - ))); - f.render_widget(par, rows[0]); - } - - let buttons_area = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(rows[1]); - - render_button( - f, - buttons_area[0], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Back, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[1], - ActionButton { - label: "Add", - intent: ButtonIntent::Primary, - focused: self.focus == Focus::AddButton, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[2], - ActionButton { - label: "Release", - intent: ButtonIntent::Destructive, - focused: self.focus == Focus::RemoveButton, - enabled: !self.loading && !self.pending && !self.users.is_empty(), - }, - context.theme, - ); - } - - fn activate(&mut self) -> InteractionResult { - if self.loading || self.pending { - return InteractionResult::Handled; - } - if let Some(dialog) = self.dialog.take() { - match dialog { - Dialog::Add { username } if !username.trim().is_empty() => { - let name = username.trim().to_owned(); - self.pending_dialog = Some(Dialog::Add { username }); - self.pending = true; - self.message = Some("Creating user…".into()); - let ipc = self.ipc.clone(); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), - Err(error) => Err(format!("Cannot create user: {error}")), - }; - UiEvent::App(AppEvent::UserCreated(result)) - }), - }; - } - Dialog::Remove { user } => { - self.pending_dialog = Some(Dialog::Remove { user: user.clone() }); - let ipc = self.ipc.clone(); - let id = user.user_id; - self.pending = true; - self.message = Some(format!("Releasing {}…", user.username)); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()), - Err(error) => Err(format!("Cannot release user: {error}")), - }; - UiEvent::App(AppEvent::UserRemoved { - user_id: id, - result, - }) - }), - }; - } - Dialog::Add { .. } => self.message = Some("A username is required.".into()), - } - return InteractionResult::Handled; - } - match self.focus { - Focus::Back => InteractionResult::CloseScreen, - Focus::AddButton => { - self.dialog = Some(Dialog::Add { - username: String::new(), - }); - InteractionResult::Handled - } - Focus::RemoveButton => { - if let Some(user) = self.users.get(self.focused_index) { - self.dialog = Some(Dialog::Remove { user: user.clone() }); - } - InteractionResult::Handled - } - Focus::List => InteractionResult::Handled, - } - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::AddButton, - Focus::AddButton => Focus::RemoveButton, - Focus::RemoveButton => Focus::Back, - Focus::Back => Focus::List, - }; - } - - fn prev_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::Back, - Focus::Back => Focus::RemoveButton, - Focus::RemoveButton => Focus::AddButton, - Focus::AddButton => Focus::List, - }; - } - - fn keep_focused_user_visible(&mut self) { - let indices = self.filtered_indices(); - let Some(position) = indices - .iter() - .position(|index| *index == self.focused_index) - else { - self.scroll_offset = 0; - return; - }; - let height = self.viewport_height.load(Ordering::Relaxed).max(1); - if position < self.scroll_offset { - self.scroll_offset = position; - } else if position >= self.scroll_offset + height { - self.scroll_offset = position + 1 - height; - } - } - - fn move_user_focus(&mut self, index: usize) { - if !self.users.is_empty() { - self.focused_index = index.min(self.users.len() - 1); - self.keep_focused_user_visible(); - } - } - - fn filtered_indices(&self) -> Vec { - let needle = self.filter.to_ascii_lowercase(); - self.users - .iter() - .enumerate() - .filter(|(_, user)| { - needle.is_empty() - || user.username.to_ascii_lowercase().contains(&needle) - || user.user_id.to_string().contains(&needle) - }) - .map(|(index, _)| index) - .collect() - } - - fn move_visible(&mut self, delta: isize) { - let indices = self.filtered_indices(); - if indices.is_empty() { - return; - } - let current = indices - .iter() - .position(|index| *index == self.focused_index) - .unwrap_or(0); - let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize; - self.move_user_focus(indices[next]); - } - - fn reset_focus_to_filter(&mut self) { - self.scroll_offset = 0; - if let Some(index) = self.filtered_indices().first().copied() { - self.focused_index = index; - } - } -} - -impl Screen for UsersScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { - let outer_block = Block::default() - .title(" Users ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Users", false, context.theme) - } else { - let inner = outer_block.inner(rect); - f.render_widget(outer_block, rect); - inner - }; - - let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner); - - self.render_user_list(f, chunks[0], context); - self.render_actions(f, chunks[1], context); - if let Some(dialog) = &self.dialog { - f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect); - let popup = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 42, - height: 7, - }, - ); - let text = match dialog { - Dialog::Add { username } => { - format!("Add user\nUsername: {username}") - } - Dialog::Remove { user } => format!( - "Remove user {} (ID {})?\nThis removes the local user record.", - user.username, user.user_id - ), - }; - let block = Block::default() - .title(" Confirm ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - let popup_inner = block.inner(popup); - f.render_widget(block, popup); - let dialog_rows = - Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); - f.render_widget( - Paragraph::new(text).style(context.theme.text.normal), - dialog_rows[0], - ); - let dialog_buttons = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(dialog_rows[1]); - render_button( - f, - dialog_buttons[0], - ActionButton { - label: "Cancel", - intent: ButtonIntent::Cancel, - focused: false, - enabled: true, - }, - context.theme, - ); - render_button( - f, - dialog_buttons[1], - ActionButton { - label: match dialog { - Dialog::Add { .. } => "Create", - Dialog::Remove { .. } => "Remove", - }, - intent: match dialog { - Dialog::Add { .. } => ButtonIntent::Primary, - Dialog::Remove { .. } => ButtonIntent::Destructive, - }, - focused: true, - enabled: true, - }, - context.theme, - ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); - } - let buttons = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(chunks[1]); - if self.dialog.is_none() { - hits.register(buttons[0], AppAction::Back); - } - if self.dialog.is_none() && !self.loading && !self.pending { - hits.register(buttons[1], AppAction::AddUser); - } - if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() { - hits.register(buttons[2], AppAction::RemoveUser); - } - if self.dialog.is_none() { - let list_height = chunks[0].height.saturating_sub(2) as usize; - let filtered_indices = self.filtered_indices(); - for visible in 0..list_height { - let position = self.scroll_offset + visible; - let Some(index) = filtered_indices.get(position).copied() else { - break; - }; - hits.register( - Rect { - x: chunks[0].x.saturating_add(1), - y: chunks[0].y.saturating_add(1 + visible as u16), - width: chunks[0].width.saturating_sub(2), - height: 1, - }, - AppAction::SelectUser(index), - ); - } - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let event = match event { - UiEvent::App(AppEvent::UsersLoaded(result)) => { - self.loading = false; - match result { - Ok(users) => { - self.users = users; - self.message = None; - } - Err(error) => self.message = Some(error), - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserCreated(result)) => { - self.pending = false; - match result { - Ok(user) => { - self.pending_dialog = None; - self.focused_index = self.users.len(); - self.users.push(user.clone()); - self.message = Some(format!( - "Created user {} ({}).", - user.username, user.user_id - )); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserRemoved { user_id, result }) => { - self.pending = false; - match result { - Ok(()) => { - self.pending_dialog = None; - if let Some(user) = - self.users.iter_mut().find(|user| user.user_id == user_id) - { - user.state = iota_ipc::LocalUserState::Released; - user.credential_present = false; - } - self.message = - Some(format!("Released user {user_id}; hosted data retained.")); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => { - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - username.push_str(&text.replace(['\r', '\n'], " ")); - } - return InteractionResult::Handled; - } - UiEvent::Key(event) => event, - _ => return InteractionResult::Unhandled, - }; - if self.filtering && self.dialog.is_none() { - match event.code { - KeyCode::Esc => { - self.filtering = false; - self.filter.clear(); - self.reset_focus_to_filter(); - } - KeyCode::Enter => self.filtering = false, - KeyCode::Backspace => { - self.filter.pop(); - self.reset_focus_to_filter(); - } - KeyCode::Char(c) - if !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.filter.push(c); - self.reset_focus_to_filter(); - } - _ => {} - } - return InteractionResult::Handled; - } - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - match event.code { - KeyCode::Esc => { - self.dialog = None; - return InteractionResult::Handled; - } - KeyCode::Enter => return self.activate(), - KeyCode::Backspace => { - username.pop(); - return InteractionResult::Handled; - } - KeyCode::Char(c) - if !c.is_control() - && !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - username.push(c); - return InteractionResult::Handled; - } - _ => return InteractionResult::Handled, - } - } - if self.dialog.is_some() { - return match event.code { - KeyCode::Esc => { - self.dialog = None; - InteractionResult::Handled - } - KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, - }; - } - match event.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Char('/') if self.focus == Focus::List => { - self.filtering = true; - self.filter.clear(); - self.reset_focus_to_filter(); - InteractionResult::Handled - } - KeyCode::Tab => { - self.next_focus(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_focus(); - InteractionResult::Handled - } - KeyCode::Down | KeyCode::Char('j') => { - if self.focus == Focus::List { - self.move_visible(1); - } - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - if self.focus == Focus::List { - self.move_visible(-1); - } - InteractionResult::Handled - } - KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(page as isize); - InteractionResult::Handled - } - KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(-(page as isize)); - InteractionResult::Handled - } - KeyCode::Home if self.focus == Focus::List => { - if let Some(index) = self.filtered_indices().first().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => { - if let Some(index) = self.filtered_indices().last().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::Back => InteractionResult::CloseScreen, - AppAction::AddUser => { - self.focus = Focus::AddButton; - self.activate() - } - AppAction::RemoveUser => { - self.focus = Focus::RemoveButton; - self.activate() - } - AppAction::SelectUser(index) if self.dialog.is_none() => { - self.focus = Focus::List; - self.move_user_focus(index); - InteractionResult::Handled - } - AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), - AppAction::CancelDialog if self.dialog.is_some() => { - self.dialog = None; - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn key_hints(&self) -> Vec { - if self.dialog.is_some() { - vec![ - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } else { - vec![ - KeyHint { - keys: "Up/Down", - action: "Select user", - }, - KeyHint { - keys: "PgUp/PgDn", - action: "Page", - }, - KeyHint { - keys: "/", - action: "Filter", - }, - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } - } -} diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs deleted file mode 100644 index 26d6046..0000000 --- a/iota-cli/src/theme/config.rs +++ /dev/null @@ -1,314 +0,0 @@ -use super::ThemeName; -use serde::{Deserialize, Serialize}; -use std::{ - fs, io, - path::{Path, PathBuf}, - str::FromStr, -}; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct UiConfig { - #[serde(default)] - pub theme: ThemeName, - /// Whether opening the interactive UI should launch a locally installed daemon. - #[serde(default)] - pub daemon_start_policy: DaemonStartPolicy, - #[serde(default)] - pub color: TerminalPolicy, - #[serde(default)] - pub unicode: TerminalPolicy, - /// Default CLI output format for headless commands. - #[serde(default)] - pub cli_output: CliOutputFormat, - /// Whether destructive CLI operations require --yes by default. - #[serde(default = "default_false")] - pub cli_require_confirmation: bool, -} - -fn default_false() -> bool { - false -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum CliOutputFormat { - #[default] - Text, - Json, - Yaml, - Table, -} - -impl CliOutputFormat { - pub fn all() -> &'static [CliOutputFormat] { - &[ - CliOutputFormat::Text, - CliOutputFormat::Json, - CliOutputFormat::Yaml, - CliOutputFormat::Table, - ] - } - - pub fn name(&self) -> &'static str { - match self { - CliOutputFormat::Text => "text", - CliOutputFormat::Json => "json", - CliOutputFormat::Yaml => "yaml", - CliOutputFormat::Table => "table", - } - } - - pub fn next(&self) -> Self { - match self { - CliOutputFormat::Text => CliOutputFormat::Json, - CliOutputFormat::Json => CliOutputFormat::Yaml, - CliOutputFormat::Yaml => CliOutputFormat::Table, - CliOutputFormat::Table => CliOutputFormat::Text, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum TerminalPolicy { - #[default] - Auto, - Always, - Never, -} - -impl TerminalPolicy { - pub fn next(&self) -> Self { - match self { - TerminalPolicy::Auto => TerminalPolicy::Always, - TerminalPolicy::Always => TerminalPolicy::Never, - TerminalPolicy::Never => TerminalPolicy::Auto, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum DaemonStartPolicy { - #[default] - Ask, - WithUi, -} -impl Serialize for DaemonStartPolicy { - fn serialize(&self, serializer: S) -> Result { - match self { - Self::Ask => serializer.serialize_str("ask"), - Self::WithUi => serializer.serialize_str("with_ui"), - } - } -} -impl<'de> Deserialize<'de> for DaemonStartPolicy { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - #[serde(untagged)] - enum Compat { - Policy(String), - Legacy(bool), - } - match Compat::deserialize(deserializer)? { - Compat::Policy(v) if v == "with_ui" || v == "WithUi" => Ok(Self::WithUi), - Compat::Policy(_) => Ok(Self::Ask), - Compat::Legacy(true) => Ok(Self::WithUi), - Compat::Legacy(false) => Ok(Self::Ask), - } - } -} -impl UiConfig { - pub fn path() -> PathBuf { - iota_paths::config_dir().join("ui.yaml") - } - - fn fallback_path() -> Option { - std::env::var_os("HOME") - .map(PathBuf::from) - .map(|d| d.join(".config").join("iota").join("ui.yaml")) - } - - pub fn load() -> Result { - let path = match (|| std::panic::catch_unwind(|| Self::path()))() { - Ok(path) => path, - Err(_) => Self::fallback_path().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "could not determine config path") - })?, - }; - Self::load_from(&path) - } - - fn load_from(path: &Path) -> Result { - if !path.exists() { - return Ok(Self::default()); - } - serde_yaml::from_str(&fs::read_to_string(path)?).map_err(io::Error::other) - } - - pub fn save(&self) -> Result<(), io::Error> { - let path = Self::path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let yaml = serde_yaml::to_string(self).map_err(io::Error::other)?; - fs::write(path, yaml) - } - - /// Load from config path, or return defaults if the config path can't be resolved. - /// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests). - pub fn load_or_default() -> Self { - Self::load().unwrap_or_default() - } - - pub fn resolve_theme(override_theme: Option) -> ThemeName { - Self::resolve_theme_from( - override_theme, - std::env::var("IOTA_THEME").ok().as_deref(), - &Self::path(), - ) - } - - fn resolve_theme_from( - override_theme: Option, - environment_theme: Option<&str>, - config_path: &Path, - ) -> ThemeName { - if let Some(theme) = override_theme { - return theme; - } - if let Some(value) = environment_theme { - match ThemeName::from_str(value) { - Ok(theme) => return theme, - Err(error) => { - eprintln!("Invalid IOTA_THEME value: {error}; checking UI configuration."); - } - } - } - match Self::load_from(config_path) { - Ok(config) => config.theme, - Err(error) => { - eprintln!( - "Could not read UI configuration {}: {error}; using ansi.", - config_path.display() - ); - ThemeName::Ansi - } - } - } - - /// Resolve the default CLI output format from config file and environment. - /// Priority: IOTA_OUTPUT env var > config file > "text" default. - pub fn resolve_cli_output(&self) -> CliOutputFormat { - if let Ok(value) = std::env::var("IOTA_OUTPUT") { - match value.to_ascii_lowercase().as_str() { - "json" => return CliOutputFormat::Json, - "yaml" | "yml" => return CliOutputFormat::Yaml, - "table" => return CliOutputFormat::Table, - _ => {} - } - } - self.cli_output - } - - /// Resolve the default --yes behavior from config file and environment. - /// Priority: IOTA_YES env var > config file > false default. - pub fn resolve_cli_require_confirmation(&self) -> bool { - if let Ok(value) = std::env::var("IOTA_YES") { - match value.to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "y" => return false, - "0" | "false" | "no" | "n" => return true, - _ => {} - } - } - self.cli_require_confirmation - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn config_path(name: &str) -> PathBuf { - std::env::temp_dir().join(format!("iota-ui-config-{}-{name}.yaml", std::process::id())) - } - - #[test] - fn command_line_override_has_highest_precedence() { - let path = config_path("override"); - fs::write(&path, "theme: surface\n").unwrap(); - let resolved = - UiConfig::resolve_theme_from(Some(ThemeName::Binary), Some("monospace"), &path); - fs::remove_file(path).unwrap(); - assert_eq!(resolved, ThemeName::Binary); - } - - #[test] - fn environment_precedes_stored_configuration() { - let path = config_path("environment"); - fs::write(&path, "theme: surface\n").unwrap(); - let resolved = UiConfig::resolve_theme_from(None, Some("monospace"), &path); - fs::remove_file(path).unwrap(); - assert_eq!(resolved, ThemeName::Monospace); - } - - #[test] - fn stored_configuration_precedes_default() { - let path = config_path("stored"); - fs::write(&path, "theme: surface\n").unwrap(); - let resolved = UiConfig::resolve_theme_from(None, None, &path); - fs::remove_file(path).unwrap(); - assert_eq!(resolved, ThemeName::Surface); - } - - #[test] - fn invalid_stored_configuration_falls_back_to_ansi() { - let path = config_path("invalid"); - fs::write(&path, "theme: ultraviolet\n").unwrap(); - let resolved = UiConfig::resolve_theme_from(None, None, &path); - fs::remove_file(path).unwrap(); - assert_eq!(resolved, ThemeName::Ansi); - } - - #[test] - fn missing_configuration_falls_back_to_ansi() { - let path = config_path("missing"); - let _ = fs::remove_file(&path); - assert_eq!( - UiConfig::resolve_theme_from(None, None, &path), - ThemeName::Ansi - ); - } - - #[test] - fn cli_output_defaults_to_text() { - let config = UiConfig::default(); - assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text); - } - - #[test] - fn cli_output_cycles_through_variants() { - assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json); - assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml); - assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table); - assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text); - } - - #[test] - fn require_confirmation_defaults_to_false() { - let config = UiConfig::default(); - assert!(!config.resolve_cli_require_confirmation()); - } - - #[test] - fn cli_output_serializes_roundtrip() { - let config = UiConfig { - cli_output: CliOutputFormat::Table, - cli_require_confirmation: true, - ..Default::default() - }; - let yaml = serde_yaml::to_string(&config).unwrap(); - let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap(); - assert_eq!(loaded.cli_output, CliOutputFormat::Table); - assert!(loaded.cli_require_confirmation); - } -} diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs deleted file mode 100644 index 3154704..0000000 --- a/iota-cli/src/theme/mod.rs +++ /dev/null @@ -1,83 +0,0 @@ -mod config; -mod model; -mod name; -mod presets; - -pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig}; -pub use model::*; -pub use name::ThemeName; - -pub fn resolve(name: ThemeName) -> ResolvedTheme { - presets::resolve(name) -} - -pub fn resolve_with_capabilities( - name: ThemeName, - color_enabled: bool, - unicode_enabled: bool, -) -> ResolvedTheme { - let mut theme = if color_enabled { - presets::resolve(name) - } else { - presets::resolve(ThemeName::Monospace) - }; - theme.name = name; - theme.unicode = unicode_enabled; - if !unicode_enabled { - if matches!(theme.console.cursor, CursorPresentation::Character { .. }) { - theme.console.cursor = CursorPresentation::Character { - glyph: "|", - style: theme.console.text, - }; - } - } - theme -} - -/// Resolve a theme against the terminal's color depth. Surface uses RGB -/// colors, so a portable ANSI preset is selected when truecolor is absent. -pub fn resolve_with_terminal_profile( - name: ThemeName, - color_enabled: bool, - unicode_enabled: bool, - truecolor_enabled: bool, -) -> ResolvedTheme { - let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) { - ThemeName::Ansi - } else { - name - }; - resolve_with_capabilities(effective, color_enabled, unicode_enabled) -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::style::Color; - - #[test] - fn no_color_policy_removes_palette_dependencies() { - let theme = resolve_with_capabilities(ThemeName::Surface, false, true); - assert_eq!(theme.name, ThemeName::Surface); - assert_eq!(theme.status.error.fg, None); - assert_eq!(theme.surfaces.panel.bg, None); - } - - #[test] - fn ascii_policy_replaces_character_cursor() { - let theme = resolve_with_capabilities(ThemeName::Monospace, false, false); - assert!(!theme.unicode); - match theme.console.cursor { - CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"), - CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"), - } - assert_ne!(theme.graphs.ram, Color::Blue); - } - - #[test] - fn surface_uses_ansi_fallback_without_truecolor() { - let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false); - assert_eq!(theme.name, ThemeName::Ansi); - assert_eq!(theme.surfaces.panel.bg, None); - } -} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs deleted file mode 100644 index 5d4955e..0000000 --- a/iota-cli/src/theme/model.rs +++ /dev/null @@ -1,168 +0,0 @@ -use super::ThemeName; -use ratatui::style::Style; - -#[derive(Clone, Debug)] -pub struct TextStyles { - pub normal: Style, - pub muted: Style, - pub heading: Style, - pub link: Style, - pub code: Style, -} -#[derive(Clone, Debug)] -pub struct StatusStyles { - pub info: Style, - pub success: Style, - pub warning: Style, - pub error: Style, -} -#[derive(Clone, Debug)] -pub struct BorderStyles { - pub normal: Style, - pub focused: Style, - pub disabled: Style, - pub title: Style, -} -#[derive(Clone, Debug)] -pub struct SurfaceStyles { - pub canvas: Style, - pub toolbar: Style, - pub panel: Style, - pub panel_alternate: Style, - pub panel_focused: Style, - pub panel_selected: Style, - pub footer: Style, - pub overlay: Style, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChromeMode { - Bordered, - Surfaces, -} -#[derive(Clone, Debug)] -pub struct ChoiceItemStyle { - pub marker: Style, - pub label: Style, - pub description: Style, - pub prefix: &'static str, - pub suffix: &'static str, -} -#[derive(Clone, Debug)] -pub struct ChoiceStyles { - pub normal: ChoiceItemStyle, - pub focused: ChoiceItemStyle, - pub selected: ChoiceItemStyle, - pub focused_selected: ChoiceItemStyle, - pub disabled: ChoiceItemStyle, - pub focused_disabled: ChoiceItemStyle, - pub selected_disabled: ChoiceItemStyle, -} -#[derive(Clone, Debug)] -pub struct ButtonStyles { - pub primary: Style, - pub primary_focused: Style, - pub neutral: Style, - pub neutral_focused: Style, - pub cancel: Style, - pub cancel_focused: Style, - pub destructive: Style, - pub disabled: Style, -} -#[derive(Clone, Debug)] -pub struct MarkerSet { - pub checkbox_unselected: &'static str, - pub checkbox_selected: &'static str, - pub radio_unselected: &'static str, - pub radio_selected: &'static str, -} -#[derive(Clone, Debug)] -pub enum CursorPresentation { - StyledCell(Style), - Character { glyph: &'static str, style: Style }, -} -#[derive(Clone, Debug)] -pub struct ConsoleStyles { - pub text: Style, - pub prefix: Style, - pub hint: Style, - pub error: Style, - pub confirmation: Style, - pub cursor: CursorPresentation, - pub border: Style, - pub focused_border: Style, - pub title: Style, -} -#[derive(Clone, Debug)] -pub struct GraphStyles { - pub ram: ratatui::style::Color, - pub cpu: ratatui::style::Color, - pub ping: ratatui::style::Color, - pub text: Style, - pub border: Style, - pub focused_border: Style, -} -#[derive(Clone, Debug)] -pub struct LogStyles { - pub call: Style, - pub client: Style, - pub iota: Style, - pub omikron: Style, - pub omega: Style, - pub command: Style, - pub other: Style, - pub text: Style, - pub error: Style, - pub timestamp: Style, - pub border: Style, - pub focused_border: Style, -} -#[derive(Clone, Debug)] -pub struct MarkdownStyles { - pub normal: Style, - pub muted: Style, - pub heading: Style, - pub link: Style, - pub code: Style, - pub table_header: Style, - pub table_text: Style, - pub divider: Style, -} -#[derive(Clone, Copy, Debug, Default)] -pub struct TextSemantics { - pub bold: bool, - pub underline: bool, -} -#[derive(Clone, Debug)] -pub struct ResolvedTheme { - pub name: ThemeName, - pub unicode: bool, - pub surfaces: SurfaceStyles, - pub chrome: ChromeMode, - pub text: TextStyles, - pub status: StatusStyles, - pub choices: ChoiceStyles, - pub buttons: ButtonStyles, - pub borders: BorderStyles, - pub console: ConsoleStyles, - pub graphs: GraphStyles, - pub logs: LogStyles, - pub markdown: MarkdownStyles, - pub markers: MarkerSet, -} - -impl ResolvedTheme { - pub fn apply_text_semantics(&self, base: Style, semantics: TextSemantics) -> Style { - use ratatui::style::Modifier; - if matches!(self.name, ThemeName::Monospace) { - return base; - } - let mut style = base; - if semantics.bold { - style = style.add_modifier(Modifier::BOLD); - } - if semantics.underline { - style = style.add_modifier(Modifier::UNDERLINED); - } - style - } -} diff --git a/iota-cli/src/theme/name.rs b/iota-cli/src/theme/name.rs deleted file mode 100644 index 1fbfeb8..0000000 --- a/iota-cli/src/theme/name.rs +++ /dev/null @@ -1,47 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::{fmt, str::FromStr}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ThemeName { - Monospace, - Binary, - #[default] - Ansi, - Surface, -} - -impl ThemeName { - pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface]; - - pub fn supported_names() -> &'static str { - "monospace, binary, ansi, surface" - } -} - -impl fmt::Display for ThemeName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Monospace => "monospace", - Self::Binary => "binary", - Self::Ansi => "ansi", - Self::Surface => "surface", - }) - } -} - -impl FromStr for ThemeName { - type Err = String; - fn from_str(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "monospace" => Ok(Self::Monospace), - "binary" => Ok(Self::Binary), - "ansi" => Ok(Self::Ansi), - "surface" => Ok(Self::Surface), - _ => Err(format!( - "unknown theme `{value}`; supported themes: {}", - Self::supported_names() - )), - } - } -} diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs deleted file mode 100644 index 301339a..0000000 --- a/iota-cli/src/theme/presets.rs +++ /dev/null @@ -1,324 +0,0 @@ -use super::{ - BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles, - CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, - StatusStyles, SurfaceStyles, TextStyles, ThemeName, -}; -use ratatui::style::{Color, Modifier, Style}; - -fn marker() -> MarkerSet { - MarkerSet { - checkbox_unselected: "[ ]", - checkbox_selected: "[x]", - radio_unselected: "( )", - radio_selected: "(x)", - } -} -fn choice( - marker: Style, - label: Style, - prefix: &'static str, - suffix: &'static str, -) -> ChoiceItemStyle { - ChoiceItemStyle { - marker, - label, - description: label, - prefix, - suffix, - } -} -fn base( - name: ThemeName, - normal: Style, - muted: Style, - focused: Style, - selected: Style, - disabled: Style, - status: StatusStyles, - buttons: ButtonStyles, -) -> ResolvedTheme { - let error = status.error; - let (prefix, suffix) = if matches!(name, ThemeName::Monospace | ThemeName::Binary) { - ("> ", " <") - } else { - ("", "") - }; - ResolvedTheme { - name, - unicode: true, - surfaces: SurfaceStyles { - canvas: Style::default(), - toolbar: Style::default(), - panel: Style::default(), - panel_alternate: Style::default(), - panel_focused: focused, - panel_selected: selected, - footer: Style::default(), - overlay: Style::default(), - }, - chrome: ChromeMode::Bordered, - text: TextStyles { - normal, - muted, - heading: normal, - link: focused, - code: normal, - }, - status, - choices: ChoiceStyles { - normal: choice(normal, normal, "", ""), - focused: choice(focused, focused, prefix, suffix), - selected: choice(selected, selected, "", ""), - focused_selected: choice( - selected.patch(focused), - selected.patch(focused), - prefix, - suffix, - ), - disabled: choice(disabled, disabled, "", ""), - focused_disabled: choice(disabled, error, prefix, suffix), - selected_disabled: choice(disabled, disabled, "", ""), - }, - buttons, - borders: BorderStyles { - normal, - focused, - disabled, - title: normal, - }, - console: ConsoleStyles { - text: normal, - prefix: muted, - hint: muted, - error, - confirmation: focused, - cursor: CursorPresentation::StyledCell(focused), - border: normal, - focused_border: focused, - title: normal, - }, - graphs: GraphStyles { - ram: Color::Reset, - cpu: Color::Reset, - ping: Color::Reset, - text: normal, - border: normal, - focused_border: focused, - }, - logs: LogStyles { - call: normal, - client: normal, - iota: normal, - omikron: normal, - omega: normal, - command: normal, - other: normal, - text: normal, - error, - timestamp: muted, - border: normal, - focused_border: focused, - }, - markdown: MarkdownStyles { - normal, - muted, - heading: focused, - link: focused, - code: focused, - table_header: focused, - table_text: normal, - divider: muted, - }, - markers: marker(), - } -} -pub fn resolve(name: ThemeName) -> ResolvedTheme { - let plain = Style::default(); - match name { - ThemeName::Monospace => { - let mut theme = base( - name, - plain, - plain, - plain, - plain, - plain, - StatusStyles { - info: plain, - success: plain, - warning: plain, - error: plain, - }, - ButtonStyles { - primary: plain, - primary_focused: plain, - neutral: plain, - neutral_focused: plain, - cancel: plain, - cancel_focused: plain, - destructive: plain, - disabled: plain, - }, - ); - theme.console.cursor = CursorPresentation::Character { - glyph: "▌", - style: plain, - }; - theme.graphs = GraphStyles { - ram: Color::Reset, - cpu: Color::Reset, - ping: Color::Reset, - text: plain, - border: plain, - focused_border: plain, - }; - theme - } - ThemeName::Binary => { - let reversed = plain.add_modifier(Modifier::REVERSED); - base( - name, - plain, - plain, - plain, - reversed, - plain, - StatusStyles { - info: plain, - success: plain, - warning: plain, - error: plain, - }, - ButtonStyles { - primary: plain, - primary_focused: reversed, - neutral: plain, - neutral_focused: reversed, - cancel: plain, - cancel_focused: reversed, - destructive: plain, - disabled: plain, - }, - ) - } - ThemeName::Ansi => { - let yellow = plain.fg(Color::Yellow).add_modifier(Modifier::BOLD); - let mut theme = base( - name, - plain, - plain.fg(Color::DarkGray), - yellow, - plain, - plain.fg(Color::DarkGray), - StatusStyles { - info: plain, - success: plain.fg(Color::Green), - warning: plain.fg(Color::Yellow), - error: plain.fg(Color::Red), - }, - ButtonStyles { - primary: plain.fg(Color::Green), - primary_focused: plain - .fg(Color::Black) - .bg(Color::Green) - .add_modifier(Modifier::BOLD), - neutral: plain, - neutral_focused: yellow, - cancel: plain.fg(Color::Red), - cancel_focused: plain - .fg(Color::Black) - .bg(Color::Red) - .add_modifier(Modifier::BOLD), - destructive: plain.fg(Color::Red), - disabled: plain.fg(Color::DarkGray), - }, - ); - theme.console = ConsoleStyles { - text: plain.fg(Color::White), - prefix: plain.fg(Color::DarkGray), - hint: plain.fg(Color::DarkGray), - error: plain.fg(Color::Red), - confirmation: plain.fg(Color::Yellow), - cursor: CursorPresentation::StyledCell(plain.fg(Color::White).bg(Color::DarkGray)), - border: plain, - focused_border: plain.fg(Color::Yellow), - title: plain.fg(Color::White), - }; - theme.graphs = GraphStyles { - ram: Color::Blue, - cpu: Color::Red, - ping: Color::Green, - text: plain, - border: plain, - focused_border: plain.fg(Color::Yellow), - }; - theme.logs = LogStyles { - call: plain.fg(Color::Magenta), - client: plain.fg(Color::Green), - iota: plain.fg(Color::Yellow), - omikron: plain.fg(Color::Blue), - omega: plain.fg(Color::Cyan), - command: plain.fg(Color::LightGreen), - other: plain.fg(Color::LightCyan), - text: plain.fg(Color::White), - error: plain.fg(Color::Red), - timestamp: plain.fg(Color::DarkGray), - border: plain, - focused_border: plain.fg(Color::Yellow), - }; - theme.markdown = MarkdownStyles { - normal: plain, - muted: plain.fg(Color::DarkGray), - heading: plain.fg(Color::Cyan), - link: plain.fg(Color::Cyan), - code: plain.fg(Color::Yellow), - table_header: plain.fg(Color::Cyan), - table_text: plain.fg(Color::Green), - divider: plain.fg(Color::DarkGray), - }; - theme - } - ThemeName::Surface => { - let focus = plain.fg(Color::Black).bg(Color::Yellow); - let selected = plain.fg(Color::Black).bg(Color::Cyan); - let mut theme = base( - name, - plain, - plain.fg(Color::DarkGray), - focus, - selected, - plain.fg(Color::DarkGray), - StatusStyles { - info: plain, - success: plain.fg(Color::Green), - warning: plain.fg(Color::Yellow), - error: plain.fg(Color::Red), - }, - ButtonStyles { - primary: plain.fg(Color::Black).bg(Color::Green), - primary_focused: focus, - neutral: plain, - neutral_focused: focus, - cancel: plain.fg(Color::Black).bg(Color::Red), - cancel_focused: focus, - destructive: plain.fg(Color::Black).bg(Color::Red), - disabled: plain.fg(Color::DarkGray), - }, - ); - theme.console.cursor = - CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); - theme.chrome = ChromeMode::Surfaces; - theme.surfaces = SurfaceStyles { - canvas: plain.bg(Color::Black), - toolbar: plain.fg(Color::White).bg(Color::DarkGray), - panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)), - panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), - panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)), - panel_selected: selected, - footer: plain.fg(Color::DarkGray).bg(Color::Black), - overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), - }; - theme - } - } -} diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs deleted file mode 100644 index ece8697..0000000 --- a/iota-cli/src/ui.rs +++ /dev/null @@ -1,728 +0,0 @@ -use crate::{ - controls::header::render_header, - help_overlay::HelpOverlay, - input_handler::setup_input_handler, - interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcClient, IpcConnectionState}, - notification::{Notification, render_notification_area}, - render_context::RenderContext, - screens::{ - main_screen::MainScreen, - metrics::MetricsScreen, - overview::OverviewScreen, - screens::{AppAction, AppEvent, HitMap, Screen, UiEvent}, - settings::SettingsScreen, - users::{UserEntry, UsersScreen}, - }, - theme::{self, ResolvedTheme, ThemeName}, -}; -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind, -}; -use once_cell::sync::Lazy; -use ratatui::{ - Terminal, - backend::CrosstermBackend, - layout::{Constraint, Layout, Rect}, -}; -use std::{ - io, - io::Stdout, - panic::PanicHookInfo, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, -}; -use tokio::sync::{Notify, RwLock, mpsc}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -/// UI state and rendering - -pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); - -pub struct UI { - ipc: RwLock>>, - shutdown_on_empty: bool, - cancellation: CancellationToken, - pub terminal: Arc>>>, - screen_stack: Arc>>>, - theme: RwLock>, - pub(crate) invalidation: Notify, - failure: Arc>>, - hits: Mutex, - app_event_tx: mpsc::UnboundedSender, - app_event_rx: Mutex>>, - header_focus: Mutex>, - notifications: Arc>>, -} - -pub fn start_tui(ipc: Arc) -> io::Result { - start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi)) -} - -pub fn start_tui_with_theme(ipc: Arc, theme: ResolvedTheme) -> io::Result { - start_session(UI::new(Some(ipc), true, theme)?) -} - -pub fn start_bootstrap_tui() -> io::Result { - start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi)) -} - -pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result { - start_session(UI::new(None, false, theme)?) -} - -fn start_session(ui: UI) -> io::Result { - let ui = Arc::new(ui); - let mut app_event_rx = ui - .app_event_rx - .lock() - .map_err(|_| io::Error::other("application event queue poisoned"))? - .take() - .ok_or_else(|| io::Error::other("application event queue already started"))?; - let app_ui = ui.clone(); - let app_event_task = tokio::spawn(async move { - loop { - tokio::select! { - _ = app_ui.cancellation.cancelled() => break, - event = app_event_rx.recv() => match event { - Some(event) => app_ui.clone().handle_event(event).await, - None => break, - }, - } - } - }); - let uic = ui.clone(); - let renderer_task = tokio::spawn(async move { - let cancellation = uic.cancellation_token(); - let result: io::Result<()> = loop { - tokio::select! { - _ = cancellation.cancelled() => break Ok(()), - _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, - } - }; - if let Err(error) = &result { - *uic.failure.lock().unwrap() = Some(error.to_string()); - uic.request_shutdown(); - } - result - }); - let input_task = setup_input_handler(ui.clone()); - // Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw - // mode. Keep this independent of key-event handling for bootstrap work. - let signal_task = { - #[cfg(unix)] - { - let signal_ui = ui.clone(); - Some(tokio::spawn(async move { - if tokio::signal::ctrl_c().await.is_ok() { - signal_ui.request_shutdown(); - } - })) - } - #[cfg(not(unix))] - { - None - } - }; - let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook()))); - let hook_for_panic = previous_hook.clone(); - std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| { - ratatui::restore(); - if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() { - hook(info); - } - })); - Ok(TuiSession { - ui, - renderer_task, - input_task, - app_event_task, - signal_task, - restored: AtomicBool::new(false), - previous_hook, - }) -} - -pub struct TuiSession { - ui: Arc, - renderer_task: JoinHandle>, - input_task: JoinHandle>, - app_event_task: JoinHandle<()>, - signal_task: Option>, - restored: AtomicBool, - previous_hook: Arc) + Send + Sync + 'static>>>>, -} - -impl TuiSession { - pub fn ui(&self) -> Arc { - self.ui.clone() - } - pub async fn shutdown(mut self) -> Option { - self.ui.request_shutdown(); - // Restore raw-mode state before waiting on cooperative tasks. A - // misbehaving task must never leave the invoking shell unusable. - self.restore_terminal_once(); - let renderer = - tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; - let input = - tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; - self.app_event_task.abort(); - if renderer.is_err() { - self.renderer_task.abort(); - } - if input.is_err() { - self.input_task.abort(); - } - if let Some(task) = self.signal_task.as_mut() { - task.abort(); - let _ = task.await; - } - self.restore_panic_hook(); - match renderer { - Err(_) => Some("renderer did not stop within 2 seconds".into()), - Ok(Err(error)) => Some(format!("renderer task failed: {error}")), - Ok(Ok(Err(error))) => Some(error.to_string()), - Ok(Ok(Ok(()))) => match input { - Err(_) => Some("input handler did not stop within 2 seconds".into()), - Ok(Err(error)) => Some(format!("input handler failed: {error}")), - Ok(Ok(Err(error))) => Some(error), - Ok(Ok(Ok(()))) => None, - }, - } - } - fn restore_terminal_once(&self) { - if !self.restored.swap(true, Ordering::AcqRel) { - let _ = crossterm::execute!(io::stdout(), DisableMouseCapture); - ratatui::restore(); - } - } - fn restore_panic_hook(&self) { - if let Some(hook) = self.previous_hook.lock().unwrap().take() { - std::panic::set_hook(hook); - } - } -} -impl Drop for TuiSession { - fn drop(&mut self) { - self.ui.request_shutdown(); - self.renderer_task.abort(); - self.input_task.abort(); - self.app_event_task.abort(); - if let Some(task) = self.signal_task.as_ref() { - task.abort(); - } - self.restore_panic_hook(); - self.restore_terminal_once(); - } -} -impl UI { - pub(crate) fn new( - ipc: Option>, - shutdown_on_empty: bool, - theme: ResolvedTheme, - ) -> io::Result { - let terminal = ratatui::try_init()?; - crossterm::execute!(io::stdout(), EnableMouseCapture)?; - let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); - Ok(Self { - ipc: RwLock::new(ipc), - shutdown_on_empty, - cancellation: CancellationToken::new(), - terminal: Arc::new(Mutex::new(terminal)), - screen_stack: Arc::new(RwLock::new(Vec::new())), - theme: RwLock::new(Arc::new(theme)), - invalidation: Notify::new(), - failure: Arc::new(Mutex::new(None)), - hits: Mutex::new(HitMap::default()), - app_event_tx, - app_event_rx: Mutex::new(Some(app_event_rx)), - header_focus: Mutex::new(None), - notifications: Arc::new(Mutex::new(Vec::new())), - }) - } - - pub async fn ipc(&self) -> Option> { - self.ipc.read().await.clone() - } - - pub async fn client_state(&self) -> Option { - self.ipc.read().await.as_ref().map(|ipc| ipc.state()) - } - - pub async fn attach_daemon(&self, ipc: Arc) { - *self.ipc.write().await = Some(ipc); - } - - pub async fn set_theme(&self, theme: ResolvedTheme) { - *self.theme.write().await = Arc::new(theme); - self.invalidate(); - } - pub async fn theme_name(&self) -> ThemeName { - self.theme.read().await.name - } - - pub fn is_shutdown(&self) -> bool { - self.cancellation.is_cancelled() - } - - pub fn request_shutdown(&self) { - self.cancellation.cancel(); - self.invalidate(); - } - pub fn invalidate(&self) { - self.invalidation.notify_one(); - } - pub fn failure(&self) -> Option { - self.failure.lock().ok().and_then(|f| f.clone()) - } - /// Lets bootstrap operations race their work against Ctrl+C without - /// blocking the input task or leaving the terminal in raw mode. - pub async fn wait_for_shutdown(&self) { - self.cancellation.cancelled().await; - } - - pub fn cancellation_token(&self) -> CancellationToken { - self.cancellation.clone() - } - - pub async fn push_notification(&self, notification: Notification) { - if let Ok(mut notifications) = self.notifications.lock() { - notifications.push(notification); - self.invalidate(); - } - } - - pub async fn clear_expired_notifications(&self) { - if let Ok(mut notifications) = self.notifications.lock() { - let before = notifications.len(); - notifications.retain(|n| !n.is_expired()); - if notifications.len() != before { - self.invalidate(); - } - } - } - - pub async fn notifications(&self) -> Vec { - self.notifications - .lock() - .map(|n| n.clone()) - .unwrap_or_default() - } - - pub async fn set_screen(&self, screen: Box) { - self.screen_stack.write().await.push(screen); - self.invalidate(); - } - pub async fn replace_screen(&self, screen: Box) { - let mut stack = self.screen_stack.write().await; - stack.clear(); - stack.push(screen); - self.invalidate(); - } - pub async fn set_root_screen(&self, screen: Box) { - let mut stack = self.screen_stack.write().await; - stack.clear(); - stack.push(screen); - self.invalidate(); - } - pub async fn handle_input(self: Arc, key_event: KeyEvent) { - self.handle_event(UiEvent::Key(key_event)).await; - } - pub async fn handle_event(self: Arc, event: UiEvent) { - if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) { - self.open_users().await; - return; - } - if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; - } - return; - } - if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event { - self.set_theme(theme::resolve(*theme)).await; - if *persist { - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); - } - return; - } - if let UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - cli_output, - cli_require_confirmation, - }) = &event - { - self.set_theme(theme::resolve(*theme)).await; - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - config.color = *color; - config.unicode = *unicode; - config.cli_output = *cli_output; - config.cli_require_confirmation = *cli_require_confirmation; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); - return; - } - if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { - let Some(ipc) = self.ipc().await else { - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::KeysRegenerated(Err( - "Not connected to daemon.".into(), - )))); - return; - }; - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::RotateIotaIdentity) - .await - { - Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot regenerate keys: {error}")) - } - Err(error) => Err(format!("Cannot regenerate keys: {error}")), - }; - let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result))); - }); - return; - } - if let UiEvent::Key(key) = &event { - let header_is_focused = self - .header_focus - .lock() - .map(|focus| focus.is_some()) - .unwrap_or(false); - if key.code == KeyCode::F(6) { - if let Ok(mut focus) = self.header_focus.lock() { - *focus = if focus.is_some() { None } else { Some(0) }; - } - self.invalidate(); - return; - } - if key.code == KeyCode::Char('?') { - let has_help_overlay = self - .screen_stack - .read() - .await - .iter() - .any(|s| s.as_any().downcast_ref::().is_some()); - if !has_help_overlay { - self.set_screen(Box::new(HelpOverlay::new())).await; - } - return; - } - if header_is_focused { - let mut action = None; - if let Ok(mut focus) = self.header_focus.lock() { - let index = focus.unwrap_or(0); - match key.code { - KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), - KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), - KeyCode::Enter | KeyCode::Char(' ') => { - action = Some( - [ - AppAction::OpenOverview, - AppAction::OpenUsers, - AppAction::OpenSettings, - AppAction::Quit, - ][index], - ); - *focus = None; - } - KeyCode::Esc => *focus = None, - _ => {} - } - } - if let Some(action) = action { - self.dispatch_action(action).await; - } else { - self.invalidate(); - } - return; - } - } - if let UiEvent::Mouse(mouse) = &event { - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) { - let action = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)); - if action == Some(AppAction::FocusLogs) { - self.dispatch_action(AppAction::FocusLogs).await; - let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) { - KeyCode::Up - } else { - KeyCode::Down - }; - // Log scrolling is a local, handled interaction; route it - // directly rather than recursively constructing another - // async UI event future. - if let Some(screen) = self.screen_stack.write().await.last_mut() { - let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key))); - } - self.invalidate(); - return; - } - } - if matches!( - mouse.kind, - MouseEventKind::Down(crossterm::event::MouseButton::Left) - ) { - if let Some(action) = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)) - { - self.dispatch_action(action).await; - return; - } - } - } - let result = { - let mut stack = self.screen_stack.write().await; - if let Some(screen) = stack.last_mut() { - screen.handle_event(event) - } else { - return; - } - }; - match result { - InteractionResult::OpenScreen { screen } => { - self.set_screen(screen).await; - } - InteractionResult::OpenFutureScreen { screen: fut } => { - let ui = self.clone(); - tokio::select! { - screen = fut => ui.set_screen(screen).await, - _ = ui.cancellation.cancelled() => return, - } - } - InteractionResult::AppTask { task } => { - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let event = task.await; - let _ = sender.send(event); - }); - } - InteractionResult::CloseScreen => { - let mut stack = self.screen_stack.write().await; - stack.pop(); - - if stack.is_empty() && self.shutdown_on_empty { - self.request_shutdown(); - } - } - InteractionResult::Handled => {} - InteractionResult::Unhandled => {} - } - self.invalidate(); - } - - async fn dispatch_action(self: &Arc, action: AppAction) { - match action { - AppAction::Quit => self.request_shutdown(), - AppAction::OpenMain => { - let mut stack = self.screen_stack.write().await; - if stack.len() > 1 { - stack.truncate(1); - } - drop(stack); - self.invalidate(); - } - AppAction::OpenOverview => { - let status = { - let stack = self.screen_stack.read().await; - stack - .iter() - .rev() - .find_map(|s| s.as_any().downcast_ref::()) - .map(|main| (main.connection_status(), main.daemon_status())) - }; - if let Some((connection, daemon)) = status { - self.set_screen(Box::new(OverviewScreen::new(connection, daemon))) - .await; - } - } - AppAction::OpenUsers => self.open_users().await, - AppAction::OpenSettings => { - let current = self.theme_name().await; - self.set_screen(Box::new(SettingsScreen::new(current))) - .await; - } - AppAction::OpenMetrics => { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; - } - } - action => { - let result = { - let mut stack = self.screen_stack.write().await; - stack.last_mut().map(|screen| screen.handle_action(action)) - }; - if matches!(result, Some(InteractionResult::CloseScreen)) { - let mut stack = self.screen_stack.write().await; - stack.pop(); - } - self.invalidate(); - } - } - } - async fn open_users(self: &Arc) { - let Some(ipc) = self.ipc().await else { return }; - self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) - .await; - let sender = self.app_event_tx.clone(); - let ui = self.clone(); - tokio::spawn(async move { - let load = async { - match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - state: u.state, - data_present: u.data_present, - credential_present: u.credential_present, - }) - .collect()) - } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) - } - Ok(_) => { - Err("Daemon returned an unexpected response while loading users.".into()) - } - Err(error) => Err(format!("Cannot load users: {error}")), - } - }; - tokio::pin!(load); - let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let result = loop { - tokio::select! { - result = &mut load => break result, - _ = ticker.tick() => { - ui.invalidate(); - } - } - }; - let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); - }); - } - - pub async fn render(&self) -> io::Result<()> { - self.clear_expired_notifications().await; - let theme = self.theme.read().await.clone(); - let context = RenderContext { - theme: theme.as_ref(), - }; - // The renderer is the only task that takes the terminal lock. Screen - // mutations use the stack lock briefly before invalidating a frame. - let stack_guard = self.screen_stack.read().await; - let (connection, daemon) = stack_guard - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| { - ( - main.connection_status().borrow().clone(), - main.daemon_status().borrow().clone(), - ) - }) - .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); - if let Some(screen) = stack_guard.last() { - let mut terminal = self - .terminal - .lock() - .map_err(|_| io::Error::other("terminal mutex poisoned"))?; - let mut hits = HitMap::default(); - terminal.draw(|f| { - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(f.area()); - let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); - render_header( - f, - rows[0], - &connection, - &daemon, - context.theme, - &mut hits, - header_focus, - ); - let hints = if header_focus.is_some() { - " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() - } else { - let mut screen_hints: Vec = screen - .key_hints() - .into_iter() - .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect(); - if !screen_hints.iter().any(|h| h.contains("?")) { - screen_hints.push("?: Help".to_owned()); - } - screen_hints.join(" ") - }; - f.render_widget( - ratatui::widgets::Paragraph::new(format!(" {hints}")).style( - context - .theme - .surfaces - .footer - .patch(context.theme.text.muted), - ), - rows[2], - ); - screen.render(f, rows[1], &context, &mut hits); - - if let Ok(notifications) = self.notifications.try_lock() { - if !notifications.is_empty() { - let notification_area = Rect { - x: rows[1].x + rows[1].width.saturating_sub(40), - y: rows[1].y, - width: 40.min(rows[1].width), - height: 3.min(rows[1].height), - }; - render_notification_area( - f, - notification_area, - ¬ifications, - context.theme, - ); - } - } - })?; - if let Ok(mut current) = self.hits.lock() { - *current = hits; - } - } - Ok(()) - } -} diff --git a/iota-cli/src/util/buttons.rs b/iota-cli/src/util/buttons.rs deleted file mode 100644 index a69d2ad..0000000 --- a/iota-cli/src/util/buttons.rs +++ /dev/null @@ -1,75 +0,0 @@ -use ratatui::layout::Rect; - -use crate::{ - controls::button::{ - ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button, - }, - theme::ResolvedTheme, - util::terms_focus::Focus, -}; - -pub fn draw_buttons( - frame: &mut ratatui::Frame, - area: Rect, - current_focus: Focus, - state: (bool, bool), - update_needed: bool, - downgrade_scenario: bool, - tos_or_privacy: bool, - theme: &ResolvedTheme, -) { - let cancel_text = if update_needed { - "[Q] Quit" - } else { - "[Q] Not now" - }; - let continue_text = if downgrade_scenario { - "Downgrade" - } else { - "Continue" - }; - let mut buttons = vec![ - (cancel_text, Focus::Cancel), - (continue_text, Focus::Continue), - ]; - if tos_or_privacy { - buttons.push(("Continue with Tensamin Services", Focus::ContinueAll)); - } - - let minimums = buttons - .iter() - .map(|(label, _)| button_minimum_width(label)) - .collect::>(); - let Some(widths) = horizontal_button_widths(area.width, &minimums) else { - return; - }; - - let mut x = area.x; - for ((label, focus), width) in buttons.iter().zip(widths) { - let button_area = Rect { - x, - y: area.y, - width, - height: area.height, - }; - x = x.saturating_add(width); - - let (intent, enabled) = match focus { - Focus::Cancel => (ButtonIntent::Cancel, true), - Focus::Continue => (ButtonIntent::Primary, state.0), - Focus::ContinueAll => (ButtonIntent::Primary, state.1), - _ => (ButtonIntent::Neutral, false), - }; - render_button( - frame, - button_area, - ActionButton { - label, - intent, - focused: current_focus == *focus, - enabled, - }, - theme, - ); - } -} diff --git a/iota-cli/tests/button_layout.rs b/iota-cli/tests/button_layout.rs deleted file mode 100644 index ad230fd..0000000 --- a/iota-cli/tests/button_layout.rs +++ /dev/null @@ -1,15 +0,0 @@ -use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths}; - -#[test] -fn width_allocation_handles_exact_spare_and_insufficient_space() { - assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4])); - assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5])); - assert_eq!(horizontal_button_widths(6, &[3, 4]), None); - assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new())); -} - -#[test] -fn minimum_width_uses_terminal_columns() { - assert_eq!(button_minimum_width("é"), 3); - assert_eq!(button_minimum_width("界"), 4); -} diff --git a/iota-cli/tests/choice_rendering.rs b/iota-cli/tests/choice_rendering.rs deleted file mode 100644 index a9ef9d3..0000000 --- a/iota-cli/tests/choice_rendering.rs +++ /dev/null @@ -1,71 +0,0 @@ -use iota_cli::{ - controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, - theme::{ThemeName, resolve}, -}; -use ratatui::style::{Color, Modifier}; - -#[test] -fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() { - let theme = resolve(ThemeName::Ansi); - let line = render_choice_line( - "Terms", - ChoiceKind::Checkbox, - ChoiceVisualState { - selected: false, - focused: true, - enabled: true, - }, - &theme, - ); - assert_eq!( - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(), - "[ ] Terms" - ); - assert_eq!(line.spans[1].style.fg, Some(Color::Yellow)); - assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD)); - - let disabled = render_choice_line( - "Terms", - ChoiceKind::Checkbox, - ChoiceVisualState { - selected: false, - focused: true, - enabled: false, - }, - &theme, - ); - assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray)); - assert_eq!(disabled.spans[3].style.fg, Some(Color::Red)); -} - -#[test] -fn colourless_themes_keep_state_and_focus_visible() { - for name in [ThemeName::Monospace, ThemeName::Binary] { - let theme = resolve(name); - let line = render_choice_line( - "Mode", - ChoiceKind::Radio, - ChoiceVisualState { - selected: true, - focused: true, - enabled: true, - }, - &theme, - ); - assert_eq!( - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(), - "> (x) Mode <" - ); - assert!( - line.spans - .iter() - .all(|span| span.style.fg.is_none() && span.style.bg.is_none()) - ); - } -} diff --git a/iota-cli/tests/control_state.rs b/iota-cli/tests/control_state.rs deleted file mode 100644 index 3089bdb..0000000 --- a/iota-cli/tests/control_state.rs +++ /dev/null @@ -1,109 +0,0 @@ -use iota_cli::controls::{ - checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem}, - navigation::DisabledFocusPolicy, - radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem}, -}; - -fn checkbox(value: u8, enabled: bool) -> CheckboxItem { - CheckboxItem { - value, - label: value.to_string(), - description: None, - enabled, - disabled_reason: None, - } -} - -fn radio(value: u8, enabled: bool) -> RadioItem { - RadioItem { - value, - label: value.to_string(), - description: None, - enabled, - disabled_reason: None, - } -} - -#[test] -fn checkbox_selection_and_disabled_focus_are_independent() { - let mut group = CheckboxGroup::new( - vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)], - [1, 99], - ) - .unwrap(); - assert_eq!( - group.selected().iter().copied().collect::>(), - vec![1] - ); - assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1)); - group.focus_next(); - assert_eq!(group.focused_item().unwrap().value, 3); - group.set_focus_policy(DisabledFocusPolicy::Include); - group.focus_previous(); - assert_eq!(group.focused_item().unwrap().value, 2); - assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2)); -} - -#[test] -fn checkbox_non_wrapping_navigation_stops_at_the_edge() { - let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap(); - group.set_wrap_navigation(false); - group.focus_previous(); - assert_eq!(group.focused_item().unwrap().value, 1); -} - -#[test] -fn radio_validates_default_and_preserves_one_selection() { - assert!(matches!( - RadioGroup::new(Vec::>::new(), None, 1), - Err(RadioGroupError::Empty) - )); - assert!(matches!( - RadioGroup::new(vec![radio(1, true)], None, 2), - Err(RadioGroupError::DefaultMissing) - )); - assert!(matches!( - RadioGroup::new(vec![radio(1, false)], None, 1), - Err(RadioGroupError::DefaultDisabled) - )); - - let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); - assert_eq!(group.selected(), &2); - group.focus_next(); - assert_eq!(group.selected(), &2); - assert_eq!(group.select_focused(), RadioChange::Unchanged(2)); - group.focus_previous(); - assert_eq!( - group.select_focused(), - RadioChange::Changed { - previous: 2, - selected: 1 - } - ); - assert_eq!(group.selected(), &1); -} - -#[test] -fn groups_initially_focus_the_first_enabled_item() { - let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap(); - assert_eq!(checkboxes.focused_item().unwrap().value, 2); - let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap(); - assert_eq!(radios.focused_item().value, 2); -} - -#[test] -fn disabling_a_selected_radio_obeys_the_configured_policy() { - let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); - group.set_enabled(&2, false).unwrap(); - assert_eq!(group.selected(), &1); - - group.set_enabled(&2, true).unwrap(); - group.focus_next(); - group.select_focused(); - group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError); - assert_eq!( - group.set_enabled(&2, false), - Err(RadioGroupError::SelectedItemDisabled) - ); - assert_eq!(group.selected(), &2); -} diff --git a/iota-cli/tests/layout_fit.rs b/iota-cli/tests/layout_fit.rs deleted file mode 100644 index f8a60aa..0000000 --- a/iota-cli/tests/layout_fit.rs +++ /dev/null @@ -1,45 +0,0 @@ -use iota_cli::layout::fit::{ - FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level, -}; -use ratatui::layout::Rect; - -#[test] -fn selects_fit_by_both_dimensions() { - let preferred = RequiredSize { - width: 80, - height: 20, - }; - let compact = RequiredSize { - width: 50, - height: 12, - }; - assert_eq!( - select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact), - FitLevel::Preferred - ); - assert_eq!( - select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact), - FitLevel::Compact - ); - assert_eq!( - select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact), - FitLevel::Fallback - ); -} - -#[test] -fn rectangle_helpers_do_not_underflow() { - let zero = Rect::new(4, 5, 0, 0); - assert_eq!( - centered_rect( - zero, - RequiredSize { - width: 10, - height: 10 - } - ), - zero - ); - assert_eq!(reserve_vertical(zero, 1, 0), None); - assert_eq!(inset_checked(zero, 1, 1), None); -} diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs deleted file mode 100644 index 666bffe..0000000 --- a/iota-cli/tests/settings_snapshot.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use iota_cli::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::{ - screens::{AppEvent, HitMap, Screen, UiEvent}, - settings::SettingsScreen, - }, - theme::{ThemeName, resolve}, -}; -use ratatui::{Terminal, backend::TestBackend}; - -fn buffer_text(terminal: &Terminal) -> String { - terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect() -} - -#[tokio::test] -async fn settings_preview_and_save_emit_typed_application_events() { - let mut screen = SettingsScreen::new(ThemeName::Ansi); - let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right))); - let InteractionResult::AppTask { task } = preview else { - panic!("theme preview should emit an application task"); - }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::ApplyTheme { - theme: ThemeName::Surface, - persist: false - }) - )); - - let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter))); - let InteractionResult::AppTask { task } = save else { - panic!("theme save should emit an application task"); - }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::SaveSettings { - theme: ThemeName::Surface, - color: _, - unicode: _, - cli_output: _, - cli_require_confirmation: _ - }) - )); -} - -#[test] -fn settings_is_readable_in_every_theme_and_layout() { - for theme_name in ThemeName::ALL { - for (width, height) in [(42, 12), (72, 20), (100, 28)] { - let theme = resolve(theme_name); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - let screen = SettingsScreen::new(theme_name); - terminal - .draw(|frame| { - screen.render( - frame, - frame.area(), - &RenderContext { theme: &theme }, - &mut HitMap::default(), - ); - }) - .unwrap(); - let rendered = buffer_text(&terminal); - assert!(rendered.contains("Settings")); - assert!(rendered.contains("Theme:")); - assert!(rendered.contains("[OK] Healthy")); - assert!(rendered.contains("[FAIL] Failed")); - } - } -} diff --git a/iota-connection/Cargo.toml b/iota-connection/Cargo.toml deleted file mode 100644 index 0148648..0000000 --- a/iota-connection/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "iota-connection" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-storage = { path = "../iota-storage" } -iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] } - -[dev-dependencies] -tokio = { version = "1.50.0", features = ["macros", "rt"] } diff --git a/iota-connection/src/connection_handler.rs b/iota-connection/src/connection_handler.rs deleted file mode 100644 index 6a20bb1..0000000 --- a/iota-connection/src/connection_handler.rs +++ /dev/null @@ -1,36 +0,0 @@ -use mtp::codec::CommunicationValue; -use std::future::Future; -use std::time::Duration; - -/// Unified interface for all connection types (Omikron, Direct, future modes). -/// -/// Provides the common messaging API that the rest of the codebase uses, -/// regardless of whether the connection goes through Omikron or is direct. -pub trait ConnectionHandler: Send + Sync { - /// Send a message to the remote end. - fn send_message( - &self, - cv: &CommunicationValue, - ) -> impl Future> + Send; - - /// Send a message and wait for a correlated response. - /// - /// The implementation correlates requests/responses by message ID and - /// enforces the given `timeout`. Returns an error on timeout or if the - /// connection drops while waiting. - fn await_response( - &self, - cv: &CommunicationValue, - timeout: Option, - ) -> impl Future> + Send; - - /// Returns `true` when the connection is alive and ready for traffic. - fn is_connected(&self) -> impl Future + Send; - - /// Returns `true` when the connection has completed identification / - /// registration and is fully operational. - fn is_identified(&self) -> impl Future + Send; - - /// Gracefully tear down the connection. - fn stop(&self) -> impl Future + Send; -} diff --git a/iota-connection/src/lib.rs b/iota-connection/src/lib.rs deleted file mode 100644 index db303ab..0000000 --- a/iota-connection/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod connection_handler; -pub mod message_common; -pub mod message_handlers; -pub mod relay; diff --git a/iota-connection/src/message_common.rs b/iota-connection/src/message_common.rs deleted file mode 100644 index 742d9b6..0000000 --- a/iota-connection/src/message_common.rs +++ /dev/null @@ -1,156 +0,0 @@ -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::type_map::TypeMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub use iota_util::mtp_compat::{MtpFieldError, OptionalDataValueExt, RequiredCommunicationFields}; - -pub trait CommunicationResponseExt { - fn with_request_id(self, request: &CommunicationValue) -> Self; -} - -impl CommunicationResponseExt for CommunicationValue { - fn with_request_id(mut self, request: &CommunicationValue) -> Self { - self = self.without_id(); - if let Some(id) = request.id() { - self = self.with_id(id); - } - self - } -} - -pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { - use mtp::type_map::{DataTypeId, TypeMap}; - let tm = TypeMap::latest(); - DataValue::Container( - items - .into_iter() - .filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv))) - .collect(), - ) -} - -pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .and_then(DataValue::as_str) - .map(|s| s.to_string()) - .or_else(|| { - cv.get_data(dt) - .and_then(DataValue::as_number) - .map(|n| n.to_string()) - }) - .or_else(|| { - cv.get_data(dt) - .and_then(DataValue::as_signed_number) - .map(|n| n.to_string()) - }) -} - -pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { - cv.get_data(dt) - .and_then(DataValue::as_number) - .and_then(|n| i64::try_from(n).ok()) - .or_else(|| { - cv.get_data(dt) - .and_then(DataValue::as_signed_number) - .and_then(|n| i64::try_from(n).ok()) - }) - .or_else(|| { - cv.get_data(dt) - .and_then(DataValue::as_str) - .and_then(|s| s.parse::().ok()) - }) -} - -#[derive(Debug, Clone)] -pub struct ChatSecretRecipient { - pub user_id: String, - pub encrypted_secret: Vec, - pub kem_ciphertext: Vec, -} - -pub fn recipient_from_value(value: &DataValue) -> Option { - let tm = TypeMap::latest(); - let user_id = value - .get_field(DataType::UserId.try_to_id(&tm)?)? - .as_str() - .map(|s| s.to_string()) - .or_else(|| { - value - .get_field(DataType::UserId.try_to_id(&tm)?)? - .as_number() - .map(|n| n.to_string()) - })?; - let encrypted_secret = value - .get_field(DataType::EncryptedSecret.try_to_id(&tm)?)? - .as_bytes()?; - let kem_ciphertext = value - .get_field(DataType::KemCiphertext.try_to_id(&tm)?)? - .as_bytes()?; - - Some(ChatSecretRecipient { - user_id, - encrypted_secret, - kem_ciphertext, - }) -} - -pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { - let recipients = cv.get_data(DataType::Recipients)?.as_array()?; - let parsed = recipients - .iter() - .map(recipient_from_value) - .collect::>>()?; - - if parsed.is_empty() { - None - } else { - Some(parsed) - } -} - -pub fn now_millis_i64() -> i64 { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - i64::try_from(millis).unwrap_or(i64::MAX) -} - -pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - let mut response = CommunicationValue::new(ty).without_id(); - if let Some(id) = request.id() { - response = response.with_id(id); - } - if let Some(sender) = request.sender() { - response = response.with_receiver(sender); - } - response -} - -#[cfg(test)] -mod tests { - use super::error_response; - use mtp::codec::{CommunicationType, CommunicationValue}; - - #[test] - fn error_response_preserves_an_absent_request_id() { - let request = CommunicationValue::new(CommunicationType::GetChats) - .without_id() - .with_sender(42); - let response = error_response(&request, CommunicationType::ErrorInvalidData); - - assert_eq!(response.id(), None); - assert_eq!(response.receiver(), Some(42)); - } - - #[test] - fn error_response_copies_an_existing_request_id() { - let request = CommunicationValue::new(CommunicationType::GetChats) - .with_id(7) - .with_sender(42); - let response = error_response(&request, CommunicationType::ErrorInvalidData); - - assert_eq!(response.id(), Some(7)); - assert_eq!(response.receiver(), Some(42)); - } -} diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs deleted file mode 100644 index 438e744..0000000 --- a/iota-connection/src/message_handlers.rs +++ /dev/null @@ -1,2407 +0,0 @@ -use crate::message_common::*; -use iota_storage::util::chat_files::{self, MessageState}; -use iota_storage::util::chats_util::{self, get_user, has_user, mod_user}; -use iota_storage::util::communities_util::CommunitiesUtil; -use iota_storage::util::e2ee_storage::{self, ChatSecretQuery}; -use iota_storage::util::settings; -use iota_storage::util::synced_settings::{self, SettingScope, SyncedSetting}; -use iota_storage::util::user_blobs::{self, UserBlob}; -use iota_storage::util::{blocked_users, message_storage_policy, receipt_policy}; -use mtp::codec::{ - CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent, -}; - -use crate::relay::VerifiedRelayContext; -use iota_storage::storage_error::StorageError; -use std::sync::atomic::{AtomicU32, Ordering}; - -static NEXT_NOTIFICATION_ID: AtomicU32 = AtomicU32::new(1); - -fn next_notification_id() -> u32 { - NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed).max(1) -} - -#[derive(Debug)] -pub struct MessageMutation { - pub sender_id: i64, - pub partner_id: i64, - pub send_time: i64, -} - -#[derive(Debug)] -pub struct SettingMutation { - pub response: CommunicationValue, - pub changed: Option, -} - -#[derive(Debug)] -pub struct BlobMutation { - pub response: CommunicationValue, - pub changed: Option, -} - -#[derive(Debug)] -pub struct PolicyMutation { - pub response: CommunicationValue, - pub changed: Option, -} - -struct SettingLocator { - scope: SettingScope, - scope_key: String, - name: String, -} - -fn required_sender_id(cv: &CommunicationValue) -> Result { - let sender = cv - .require_sender() - .map_err(|_| error_response(cv, CommunicationType::ErrorInvalidData))?; - i64::try_from(sender).map_err(|_| error_response(cv, CommunicationType::ErrorInvalidData)) -} - -fn sender_wire_id(sender_id: i64) -> u64 { - u64::try_from(sender_id).expect("validated authenticated sender is non-negative") -} - -pub fn message_mutation(cv: &CommunicationValue) -> Result { - let sender_id = required_sender_id(cv)?; - let partner_id = data_i64(cv, DataType::ChatPartnerId) - .filter(|id| *id > 0) - .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; - let send_time = data_i64(cv, DataType::SendTime) - .filter(|time| *time > 0) - .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; - - Ok(MessageMutation { - sender_id, - partner_id, - send_time, - }) -} - -pub fn success_response(cv: &CommunicationValue) -> CommunicationValue { - error_response(cv, CommunicationType::Success) -} - -fn add_conversation_for_user( - user_id: i64, - other_id: i64, - name: Option<&str>, -) -> Result<(), StorageError> { - let mut contact = get_user(user_id, other_id)? - .unwrap_or_else(|| iota_storage::users::contact::Contact::new(other_id)); - if let Some(name) = name { - contact.user_name = Some(name.to_string()); - } - mod_user(user_id, &contact) -} - -fn relay_field<'a>( - payload: &'a DataValue, - data_type: DataType, - type_map: &TypeMap, -) -> Option<&'a DataValue> { - payload.get_field(data_type.try_to_id(type_map)?) -} - -fn relay_string<'a>( - payload: &'a DataValue, - data_type: DataType, - type_map: &TypeMap, -) -> Option<&'a str> { - relay_field(payload, data_type, type_map)?.as_str() -} - -fn relay_number(payload: &DataValue, data_type: DataType, type_map: &TypeMap) -> Option { - relay_field(payload, data_type, type_map)?.as_number() -} - -fn relay_identity( - payload: &DataValue, - data_type: DataType, - type_map: &TypeMap, -) -> Result, String> { - let Some(value) = relay_field(payload, data_type, type_map) else { - return Ok(None); - }; - if let Some(number) = value.as_number() { - return u64::try_from(number) - .map(Some) - .map_err(|_| format!("Relay {data_type:?} is outside the user ID range")); - } - if let Some(text) = value.as_str() { - return text - .parse::() - .map(Some) - .map_err(|_| format!("Relay {data_type:?} is not a user ID")); - } - Err(format!("Relay {data_type:?} has an invalid user ID value")) -} - -fn validate_relay_identity( - context: &VerifiedRelayContext, - payload: &DataValue, -) -> Result<(), String> { - if relay_identity(payload, DataType::SenderId, &context.type_map)? - .is_some_and(|sender_id| sender_id != context.signer_id) - { - return Err("Relay SenderId does not match the authenticated signer".into()); - } - if relay_identity(payload, DataType::ReceiverId, &context.type_map)? - .is_some_and(|receiver_id| receiver_id != context.final_recipient_id) - { - return Err("Relay ReceiverId does not match the authenticated recipient".into()); - } - Ok(()) -} - -/* - * Apply only operations whose actor and recipient can be taken from verified - * Relay metadata. The raw Relay frame never enters these handlers, so outer - * routing fields cannot become application identity. - */ -pub fn apply_verified_relay_content( - context: &VerifiedRelayContext, - content: &VerifiedRelayContent, - accepted_at: i64, - storage_owner: i64, - sent_by_self: bool, -) -> Result<(), String> { - validate_relay_identity(context, &content.content)?; - let sender_id = i64::try_from(context.signer_id) - .map_err(|_| "Relay signer ID exceeds the local storage range".to_string())?; - let recipient_id = i64::try_from(context.final_recipient_id) - .map_err(|_| "Relay recipient ID exceeds the local storage range".to_string())?; - let created_at = i64::try_from(context.created_at) - .map_err(|_| "Relay creation time exceeds the local storage range".to_string())?; - - match content.message_type.as_str() { - "MessageState" => { - let partner_id = - relay_number(&content.content, DataType::ChatPartnerId, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .filter(|id| *id == recipient_id) - .ok_or_else(|| "Relay MessageState has an invalid ChatPartnerId".to_string())?; - let relay_message_id = relay_string( - &content.content, - DataType::RelayMessageId, - &context.type_map, - ) - .ok_or_else(|| "Relay MessageState is missing RelayMessageId".to_string())?; - let event_at = relay_number(&content.content, DataType::EventAt, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .ok_or_else(|| "Relay MessageState is missing EventAt".to_string())?; - let state = relay_string(&content.content, DataType::MessageState, &context.type_map) - .map(MessageState::from_str) - .filter(|state| matches!(state, MessageState::Received | MessageState::Read)) - .ok_or_else(|| "Relay MessageState has an invalid state".to_string())?; - if sent_by_self { - return chat_files::change_message_state_by_relay_id( - storage_owner, - recipient_id, - relay_message_id, - state, - ) - .map_err(|error| error.to_string()); - } - chat_files::record_message_receipt( - storage_owner, - recipient_id, - relay_message_id, - sender_id, - &context.message_id, - state, - event_at, - now_millis_i64(), - ) - .map_err(|error| error.to_string())?; - let _ = partner_id; - Ok(()) - } - "MessageSend" => { - let message = relay_string(&content.content, DataType::Content, &context.type_map) - .ok_or_else(|| "Relay MessageSend is missing Content".to_string())?; - let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .unwrap_or(created_at); - let height = relay_number(&content.content, DataType::Height, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .unwrap_or_default(); - let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map) - .and_then(|value| i64::try_from(value).ok()); - if relay_string( - &content.content, - DataType::RelayMessageId, - &context.type_map, - ) - .is_some_and(|relay_message_id| relay_message_id != context.message_id) - { - return Err( - "Relay MessageSend identity does not match its protected message ID".into(), - ); - } - chat_files::add_message(chat_files::NewMessage { - relay_signer_id: sender_id, - relay_message_id: &context.message_id, - authored_at: created_at, - send_time, - storage_owner, - external_user: if sent_by_self { - recipient_id - } else { - sender_id - }, - sent_by_self, - content: message, - height, - reply_to, - origin_iota_received_at: sent_by_self.then_some(accepted_at), - destination_iota_received_at: (!sent_by_self).then_some(accepted_at), - initial_state: if sent_by_self { - MessageState::Sending - } else { - MessageState::Sent - }, - }) - .map_err(|error| error.to_string())?; - Ok(()) - } - "MessageEdit" => { - let message = relay_string(&content.content, DataType::Content, &context.type_map) - .ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?; - let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .ok_or_else(|| "Relay MessageEdit is missing SendTime".to_string())?; - chat_files::apply_remote_edit(recipient_id, sender_id, send_time, sender_id, message) - .map_err(|error| error.to_string()) - } - "MessageReactionAdd" | "MessageReactionRemove" => { - let reaction = relay_string(&content.content, DataType::Reaction, &context.type_map) - .filter(|value| !value.is_empty() && value.len() <= 64) - .ok_or_else(|| "Relay reaction is invalid".to_string())?; - let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .ok_or_else(|| "Relay reaction is missing SendTime".to_string())?; - let result = if content.message_type == "MessageReactionAdd" { - chat_files::add_reaction(recipient_id, sender_id, send_time, sender_id, reaction) - } else { - chat_files::remove_reaction(recipient_id, sender_id, send_time, sender_id, reaction) - }; - result.map_err(|error| error.to_string()) - } - "MessageDeleteLive" => { - let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .ok_or_else(|| "Relay MessageDeleteLive is missing SendTime".to_string())?; - chat_files::apply_remote_delete(recipient_id, sender_id, send_time, sender_id) - .map_err(|error| error.to_string()) - } - "SetChatSecret" => { - let frame = CommunicationValue::new(CommunicationType::SetChatSecret) - .with_payload(content.content.clone()); - let recipients = chat_secret_recipients(&frame) - .ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?; - let recipient = recipients - .into_iter() - .find(|value| value.user_id == storage_owner.to_string()) - .ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?; - let chat_id = data_string(&frame, DataType::ChatId) - .ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?; - let secret_id = data_string(&frame, DataType::SecretId) - .ok_or_else(|| "Relay SetChatSecret is missing SecretId".to_string())?; - let version = data_i64(&frame, DataType::VersionNumber) - .ok_or_else(|| "Relay SetChatSecret is missing VersionNumber".to_string())?; - let wrapping_scheme = data_string(&frame, DataType::WrappingScheme) - .ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?; - e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { - user_id: storage_owner.to_string(), - chat_id, - secret_id, - version, - encrypted_secret: recipient.encrypted_secret, - kem_ciphertext: recipient.kem_ciphertext, - wrapping_scheme, - created_at, - updated_at: now_millis_i64(), - }) - .map_err(|error| error.to_string()) - } - "AddConversation" => { - let other_id = - relay_number(&content.content, DataType::ChatPartnerId, &context.type_map) - .and_then(|value| i64::try_from(value).ok()) - .filter(|id| *id > 0) - .ok_or_else(|| { - "Relay AddConversation has an invalid ChatPartnerId".to_string() - })?; - let user_id = storage_owner; - if user_id <= 0 { - return Err("Relay AddConversation has an invalid storage owner".into()); - } - add_conversation_for_user( - user_id, - other_id, - relay_string( - &content.content, - DataType::ChatPartnerName, - &context.type_map, - ), - ) - .map_err(|error| format!("AddConversation persistence failed: {error}")) - } - _ => Ok(()), - } -} - -/* Local receipt disclosure is checked before the relay enters durable state. */ -pub fn validate_outgoing_receipt_policy( - sender_id: i64, - context: &VerifiedRelayContext, - content: &VerifiedRelayContent, -) -> Result<(), String> { - if content.message_type != "MessageState" { - return Ok(()); - } - let state = relay_string(&content.content, DataType::MessageState, &context.type_map) - .map(MessageState::from_str) - .filter(|state| matches!(state, MessageState::Received | MessageState::Read)) - .ok_or_else(|| "local MessageState has an invalid state".to_string())?; - let policy = receipt_policy::get(sender_id).map_err(|error| error.to_string())?; - let allowed = match state { - MessageState::Read => policy.send_read_receipts, - MessageState::Received => policy.send_received_receipts, - MessageState::Sent | MessageState::Sending => false, - }; - if allowed { - Ok(()) - } else { - Err("local receipt disclosure is disabled by policy".into()) - } -} - -pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { - let mutation = match message_mutation(cv) { - Ok(mutation) => mutation, - Err(response) => return response, - }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - - match chat_files::edit_message( - mutation.sender_id, - mutation.partner_id, - mutation.send_time, - mutation.sender_id, - content, - ) { - Ok(()) => success_response(cv), - Err(_) => error_response(cv, CommunicationType::ErrorNotFound), - } -} - -pub fn handle_message_reaction(cv: &CommunicationValue, add: bool) -> CommunicationValue { - let mutation = match message_mutation(cv) { - Ok(mutation) => mutation, - Err(response) => return response, - }; - let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - if reaction.is_empty() || reaction.len() > 64 { - return error_response(cv, CommunicationType::ErrorInvalidData); - } - - let result = if add { - chat_files::add_reaction( - mutation.sender_id, - mutation.partner_id, - mutation.send_time, - mutation.sender_id, - reaction, - ) - } else { - chat_files::remove_reaction( - mutation.sender_id, - mutation.partner_id, - mutation.send_time, - mutation.sender_id, - reaction, - ) - }; - - match result { - Ok(()) => success_response(cv), - Err(iota_storage::storage_error::StorageError::ReactionLimitReached) => { - error_response(cv, CommunicationType::ErrorInvalidData) - } - Err(_) => error_response(cv, CommunicationType::ErrorNotFound), - } -} - -pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue { - let mutation = match message_mutation(cv) { - Ok(mutation) => mutation, - Err(response) => return response, - }; - - match chat_files::delete_message(mutation.sender_id, mutation.partner_id, mutation.send_time) { - Ok(()) => success_response(cv), - Err(_) => error_response(cv, CommunicationType::ErrorNotFound), - } -} - -fn stored_message_fields( - message: &chat_files::StoredMessage, - storage_owner: i64, - partner_id: i64, -) -> Vec<(DataType, DataValue)> { - let mut fields = vec![ - ( - DataType::MessageId, - DataValue::SignedNumber(message.id as i128), - ), - ( - DataType::SendTime, - DataValue::SignedNumber(message.message_time as i128), - ), - (DataType::Content, DataValue::Str(message.content.clone())), - ( - DataType::MessageState, - DataValue::Str(message.message_state.clone()), - ), - ( - DataType::Height, - DataValue::SignedNumber(message.height as i128), - ), - ]; - let sender_id = if message.sent_by_self { - storage_owner - } else { - partner_id - }; - if let Ok(sender_id) = u128::try_from(sender_id) { - fields.push((DataType::SenderId, DataValue::UnsignedNumber(sender_id))); - } - if let Some(relay_message_id) = &message.relay_message_id { - fields.push(( - DataType::RelayMessageId, - DataValue::Str(relay_message_id.clone()), - )); - } - if let Some(relay_signer_id) = message.relay_signer_id { - fields.push(( - DataType::SenderId, - DataValue::SignedNumber(relay_signer_id.into()), - )); - } - for (data_type, timestamp) in [ - (DataType::AuthoredAt, message.authored_at), - ( - DataType::OriginIotaReceivedAt, - message.origin_iota_received_at, - ), - ( - DataType::DestinationIotaReceivedAt, - message.destination_iota_received_at, - ), - (DataType::ClientReceivedAt, message.client_received_at), - ( - DataType::ClientReceivedRecordedAt, - message.client_received_recorded_at, - ), - (DataType::ReadAt, message.read_at), - (DataType::ReadRecordedAt, message.read_recorded_at), - ] { - if let Some(timestamp) = timestamp { - fields.push((data_type, DataValue::SignedNumber(timestamp.into()))); - } - } - if let Some(failed_at) = message.delivery_failed_at { - fields.push(( - DataType::UpdatedAt, - DataValue::SignedNumber(failed_at.into()), - )); - } - if let Some(failure) = &message.delivery_failure { - fields.push((DataType::ErrorType, DataValue::Str(failure.clone()))); - } - if message.edited { - fields.push((DataType::Edited, DataValue::Bool(true))); - } - if let Some(reply_to) = message.reply_to.and_then(|id| u64::try_from(id).ok()) { - fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(u128::from(reply_to)), - )); - } - if !message.reactions.is_empty() { - let reactions = message - .reactions - .iter() - .map(|reaction| { - typed_container(vec![ - ( - DataType::Reaction, - DataValue::Str(reaction.reaction.clone()), - ), - ( - DataType::SenderId, - DataValue::SignedNumber(reaction.user_id as i128), - ), - ]) - }) - .collect(); - fields.push((DataType::Reactions, DataValue::Array(reactions))); - } - fields -} - -fn stored_message_value( - message: &chat_files::StoredMessage, - storage_owner: i64, - partner_id: i64, -) -> DataValue { - typed_container(stored_message_fields(message, storage_owner, partner_id)) -} - -fn synced_setting_value(setting: &SyncedSetting) -> DataValue { - typed_container(vec![ - ( - DataType::SettingId, - DataValue::SignedNumber(setting.id.into()), - ), - ( - DataType::SettingScope, - DataValue::Str(setting.scope.as_str().to_string()), - ), - ( - DataType::SettingTarget, - DataValue::Str(setting.scope_key.clone()), - ), - (DataType::SettingsName, DataValue::Str(setting.name.clone())), - (DataType::Payload, DataValue::Str(setting.payload.clone())), - ( - DataType::VersionNumber, - DataValue::SignedNumber(setting.revision.into()), - ), - ]) -} - -pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { - let Some(user_id) = data_string(cv, DataType::UserId) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - if user_id != sender_id.to_string() { - return error_response(cv, CommunicationType::ErrorNotFound); - } - let Some(chat_id) = data_string(cv, DataType::ChatId) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - - match e2ee_storage::get_chat_secret(ChatSecretQuery { - user_id, - chat_id, - secret_id: data_string(cv, DataType::SecretId), - }) { - Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) - .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret), - ) - .add_typed_default( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default( - DataType::UpdatedAt, - DataValue::SignedNumber(record.updated_at as i128), - ), - Ok(None) => error_response(cv, CommunicationType::ErrorNotSet), - Err(_) => error_response(cv, CommunicationType::ErrorInvalidData), - } -} - -pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() && !app_public_key.is_empty() { - let user = match iota_storage::users::user_manager::get_user(sender_id) { - Ok(user) => user, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - if let Some(mut user) = user { - if !user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.insert(app_identifier, app_public_key); - iota_storage::users::user_manager::update_user(user); - } - } - } - - CommunicationValue::new(CommunicationType::CreateApp) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - -pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() { - let user = match iota_storage::users::user_manager::get_user(sender_id) { - Ok(user) => user, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - if let Some(mut user) = user { - if user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.remove(&app_identifier); - iota_storage::users::user_manager::update_user(user); - } - } - } - - CommunicationValue::new(CommunicationType::DeleteApp) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - -fn contact_value( - contact: &iota_storage::users::contact::Contact, - messages: &[chat_files::StoredMessage], - storage_owner: i64, -) -> DataValue { - let mut fields = vec![( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )]; - if let Some(name) = &contact.user_name { - fields.push((DataType::Username, DataValue::Str(name.clone()))); - } - if contact.created_at > 0 { - fields.push(( - DataType::CreatedAt, - DataValue::SignedNumber(contact.created_at.into()), - )); - } - if let Some(last_message_at) = contact.last_message_at { - fields.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(last_message_at as i128), - )); - } - fields.push(( - DataType::Messages, - DataValue::Array( - messages - .iter() - .filter(|message| message.external_user == contact.user_id) - .map(|message| stored_message_value(message, storage_owner, contact.user_id)) - .collect(), - ), - )); - typed_container(fields) -} - -fn current_contact_ids(user_id: i64) -> Result { - Ok(contact_ids_value( - chats_util::get_users(user_id)? - .into_iter() - .map(|contact| contact.user_id), - )) -} - -fn contact_ids_value(ids: impl IntoIterator) -> DataValue { - let mut contact_ids = ids.into_iter().collect::>(); - contact_ids.sort_unstable(); - contact_ids.dedup(); - - DataValue::Array( - contact_ids - .into_iter() - .map(|user_id| DataValue::SignedNumber(user_id as i128)) - .collect(), - ) -} - -#[cfg(test)] -mod presence_tests { - use super::{contact_ids_value, handle_get_chats, message_mutation}; - use mtp::codec::{CommunicationType, CommunicationValue, DataValue}; - - #[test] - fn contact_snapshot_is_sorted_and_deduplicated() { - assert_eq!( - contact_ids_value([9, 3, 9, 4, 3]), - DataValue::Array(vec![ - DataValue::SignedNumber(3), - DataValue::SignedNumber(4), - DataValue::SignedNumber(9), - ]) - ); - } - - #[test] - fn message_mutation_rejects_a_missing_authenticated_sender() { - let request = CommunicationValue::new(CommunicationType::MessageEdit).with_id(11); - let response = message_mutation(&request).expect_err("missing sender must be rejected"); - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - assert_eq!(response.id(), Some(11)); - assert_eq!(response.receiver(), None); - } - - #[test] - fn read_handler_rejects_a_missing_authenticated_sender() { - let request = CommunicationValue::new(CommunicationType::GetChats).with_id(12); - let response = handle_get_chats(&request); - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - assert_eq!(response.id(), Some(12)); - assert_eq!(response.receiver(), None); - } -} - -fn sync_error(cv: &CommunicationValue) -> CommunicationValue { - error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( - DataType::SessionId, - cv.get_data(DataType::SessionId) - .cloned() - .unwrap_or(DataValue::Null), - ) -} - -/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. -pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { - use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; - let user_id = match required_sender_id(cv) { - Ok(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let session_id = match data_i64(cv, DataType::SessionId) { - Some(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let reported_version = match data_i64(cv, DataType::VersionNumber) { - Some(version) if version >= 0 => version, - _ => return sync_error(cv), - }; - let cache_valid = cv.get_data(DataType::CacheValid).as_bool().unwrap_or(false); - let schema = data_i64(cv, DataType::CacheSchemaVersion).unwrap_or(0); - let head = match sync::head(user_id) { - Ok(version) => version, - Err(_) => return sync_error(cv), - }; - let known_session = sync::has_session(user_id, session_id).unwrap_or(false); - let acknowledged_version = sync::acknowledged_version(user_id, session_id).unwrap_or(None); - let full = !cache_valid - || reported_version == 0 - || !known_session - || acknowledged_version.is_some_and(|version| reported_version < version) - || reported_version > head - || schema != CACHE_SCHEMA_VERSION; - let (contacts, messages, settings, deleted_messages, deleted_contacts, deleted_settings, mode) = - if full { - let settings = match synced_settings::list(user_id) { - Ok(settings) => settings, - Err(_) => return sync_error(cv), - }; - ( - match chats_util::get_users(user_id) { - Ok(contacts) => contacts, - Err(_) => return sync_error(cv), - }, - chat_files::get_all_messages(user_id), - settings, - Vec::new(), - Vec::new(), - Vec::new(), - "full", - ) - } else { - match sync::delta(user_id, reported_version, head) { - Ok(delta) => { - let settings = - match synced_settings::list_by_ids(user_id, &delta.setting_upserts) { - Ok(settings) => settings, - Err(_) => return sync_error(cv), - }; - ( - match chats_util::get_users_by_ids(user_id, &delta.contact_upserts) { - Ok(contacts) => contacts, - Err(_) => return sync_error(cv), - }, - chat_files::get_messages_by_ids(user_id, &delta.message_upserts), - settings, - delta.deleted_message_ids, - delta.deleted_contact_ids, - delta.deleted_setting_ids, - "delta", - ) - } - Err(_) => { - let settings = match synced_settings::list(user_id) { - Ok(settings) => settings, - Err(_) => return sync_error(cv), - }; - ( - match chats_util::get_users(user_id) { - Ok(contacts) => contacts, - Err(_) => return sync_error(cv), - }, - chat_files::get_all_messages(user_id), - settings, - Vec::new(), - Vec::new(), - Vec::new(), - "full", - ) - } - } - }; - let message_values = messages - .iter() - .map(|message| stored_message_value(message, user_id, message.external_user)) - .collect(); - if iota_storage::util::client_message_delivery::record_sync_delivery( - user_id, - session_id, - head, - messages.iter().map(|message| message.id), - ) - .is_err() - { - return sync_error(cv); - } - let (blobs, deleted_blobs) = if mode == "delta" { - match sync::delta(user_id, reported_version, head) { - Ok(delta) => { - let blobs = match user_blobs::list_by_ids(user_id, &delta.blob_upserts) { - Ok(blobs) => blobs, - Err(_) => return sync_error(cv), - }; - let deleted = - match user_blobs::list_deleted_by_ids(user_id, &delta.deleted_blob_ids) { - Ok(blobs) => blobs, - Err(_) => return sync_error(cv), - }; - (blobs, deleted) - } - Err(_) => return sync_error(cv), - } - } else { - match user_blobs::list(user_id) { - Ok(blobs) => (blobs, Vec::new()), - Err(_) => return sync_error(cv), - } - }; - let blocked_users = match blocked_users::list(user_id) { - Ok(users) => users, - Err(_) => return sync_error(cv), - }; - let receipt_policy = match receipt_policy::get(user_id) { - Ok(policy) => policy, - Err(_) => return sync_error(cv), - }; - let message_storage_policy = match message_storage_policy::get(user_id) { - Ok(policy) => policy, - Err(_) => return sync_error(cv), - }; - let contact_ids = match current_contact_ids(user_id) { - Ok(contact_ids) => contact_ids, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - let retention_duration = match message_storage_policy.retention { - message_storage_policy::MessageRetention::Forever => None, - message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms), - }; - let mut response = CommunicationValue::new(CommunicationType::ClientStateSync) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(head as i128), - ) - .add_typed_default( - DataType::CacheSchemaVersion, - DataValue::SignedNumber(CACHE_SCHEMA_VERSION as i128), - ) - .add_typed_default(DataType::SyncMode, DataValue::Str(mode.into())) - .add_typed_default( - DataType::Contacts, - DataValue::Array( - contacts - .iter() - .map(|contact| contact_value(contact, &messages, user_id)) - .collect(), - ), - ) - .add_typed_default(DataType::Messages, DataValue::Array(message_values)) - .add_typed_default( - DataType::Settings, - DataValue::Array(settings.iter().map(synced_setting_value).collect()), - ) - .add_typed_default( - DataType::Blobs, - DataValue::Array(blobs.iter().map(blob_value).collect()), - ) - .add_typed_default( - DataType::DeletedBlobIds, - DataValue::Array( - deleted_blobs - .iter() - .map(|blob| DataValue::Str(blob.blob_id.clone())) - .collect(), - ), - ) - .add_typed_default( - DataType::BlockedUserIds, - DataValue::Array( - blocked_users - .into_iter() - .map(|id| DataValue::SignedNumber(id.into())) - .collect(), - ), - ) - .add_typed_default( - DataType::SendReadReceipts, - DataValue::Bool(receipt_policy.send_read_receipts), - ) - .add_typed_default( - DataType::SendReceivedReceipts, - DataValue::Bool(receipt_policy.send_received_receipts), - ) - .add_typed_default( - DataType::MessageHistoryMode, - DataValue::Str(match message_storage_policy.history_mode { - message_storage_policy::MessageHistoryMode::Retain => "retain".into(), - message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery => { - "delete_after_client_delivery".into() - } - }), - ) - .add_typed_default( - DataType::Communities, - DataValue::Array(community_values(user_id)), - ) - .add_typed_default( - DataType::DeletedMessageIds, - DataValue::Array( - deleted_messages - .into_iter() - .map(|id| DataValue::SignedNumber(id as i128)) - .collect(), - ), - ) - .add_typed_default( - DataType::DeletedContactIds, - DataValue::Array( - deleted_contacts - .into_iter() - .map(|id| DataValue::SignedNumber(id as i128)) - .collect(), - ), - ) - .add_typed_default( - DataType::DeletedSettingIds, - DataValue::Array( - deleted_settings - .into_iter() - .map(|id| DataValue::SignedNumber(id as i128)) - .collect(), - ), - ) - .add_typed_default(DataType::UserIds, contact_ids) - .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())); - if let Some(duration_ms) = retention_duration { - response = response.add_typed_default( - DataType::MessageRetentionDuration, - DataValue::SignedNumber(duration_ms.into()), - ); - } - response -} - -pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { - use iota_storage::util::sync::CACHE_SCHEMA_VERSION; - let user_id = match required_sender_id(cv) { - Ok(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let session_id = match data_i64(cv, DataType::SessionId) { - Some(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let version = match data_i64(cv, DataType::VersionNumber) { - Some(version) if version >= 0 => version, - _ => return sync_error(cv), - }; - if iota_storage::util::client_message_delivery::acknowledge_client_state( - user_id, - session_id, - version, - CACHE_SCHEMA_VERSION, - ) - .is_err() - { - return sync_error(cv); - } - success_response(cv) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(version as i128), - ) -} - -pub fn handle_messages_get(cv: &CommunicationValue) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(partner_id) = data_i64(cv, DataType::UserId).filter(|id| *id > 0) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(offset) = data_i64(cv, DataType::Offset).filter(|offset| *offset >= 0) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(amount) = data_i64(cv, DataType::Amount).filter(|amount| *amount > 0) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let messages = chat_files::get_messages(my_id_i64, partner_id, offset, amount); - let mut msg_array: Vec = Vec::new(); - for m in &messages { - msg_array.push(stored_message_value(m, my_id_i64, partner_id)); - } - - CommunicationValue::new(CommunicationType::MessagesGet) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default(DataType::Messages, DataValue::Array(msg_array)) -} - -pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { - let Some(send_time) = data_i64(cv, DataType::SendTime) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let partner_id = data_i64(cv, DataType::ChatPartnerId); - let owner = match required_sender_id(cv) { - Ok(owner) => owner, - Err(response) => return response, - }; - - let (message, offset) = match partner_id { - Some(partner_id) => match chat_files::get_message_with_offset(owner, partner_id, send_time) - { - Ok(Some((message, offset))) => (message, Some(offset)), - Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }, - None => match chat_files::get_message(owner, send_time, None) { - Ok(Some(message)) => (message, None), - Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }, - }; - - let mut response = CommunicationValue::new(CommunicationType::MessageGet) - .with_request_id(cv) - .with_receiver(u64::try_from(owner).expect("authenticated sender is non-negative")); - for (data_type, value) in stored_message_fields(&message, owner, message.external_user) { - response = response.add_typed_default(data_type, value); - } - if let Some(offset) = offset { - response = - response.add_typed_default(DataType::Offset, DataValue::SignedNumber(offset as i128)); - } - response -} - -pub fn handle_get_chats(cv: &CommunicationValue) -> CommunicationValue { - let user_id = match cv.require_sender() { - Ok(user_id) => user_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(user_id_i64) = i64::try_from(user_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let users = match chats_util::get_users(user_id_i64) { - Ok(users) => users, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - let mut user_array = Vec::new(); - for user in users { - let mut container = Vec::new(); - container.push(( - DataType::UserId, - DataValue::SignedNumber(user.user_id as i128), - )); - if let Some(name) = user.user_name { - container.push((DataType::Username, DataValue::Str(name))); - } - if user.created_at > 0 { - container.push(( - DataType::CreatedAt, - DataValue::SignedNumber(user.created_at.into()), - )); - } - if let Some(ts) = user.last_message_at { - container.push((DataType::LastMessageAt, DataValue::SignedNumber(ts as i128))); - } - user_array.push(typed_container(container)); - } - CommunicationValue::new(CommunicationType::GetChats) - .with_request_id(cv) - .with_receiver(user_id) - .add_typed_default(DataType::UserIds, DataValue::Array(user_array)) -} - -pub fn handle_add_community(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let Some(address) = cv.get_data(DataType::CommunityAddress).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(title) = cv.get_data(DataType::CommunityTitle).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(position) = cv.get_data(DataType::Position).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - CommunitiesUtil::add_community( - sender_id, - address.to_string(), - title.to_string(), - position.to_string(), - ); - CommunicationValue::new(CommunicationType::AddCommunity) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - -pub fn handle_get_communities(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - CommunicationValue::new(CommunicationType::GetCommunities) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) - .add_typed_default( - DataType::Communities, - DataValue::Array(community_values(sender_id)), - ) -} - -fn community_values(storage_owner: i64) -> Vec { - CommunitiesUtil::get_communities(storage_owner) - .into_iter() - .map(|community| { - typed_container(vec![ - ( - DataType::CommunityAddress, - DataValue::Str(community.address), - ), - (DataType::CommunityTitle, DataValue::Str(community.title)), - (DataType::Position, DataValue::Str(community.position)), - ]) - }) - .collect() -} - -pub fn handle_remove_community(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let Some(address) = cv.get_data(DataType::CommunityAddress).as_str() else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - if CommunitiesUtil::remove_community(sender_id, address.to_string()).is_err() { - return error_response(cv, CommunicationType::ErrorInternal); - } - CommunicationValue::new(CommunicationType::RemoveCommunity) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - -pub fn handle_global_settings_save(cv: &CommunicationValue) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ); - }; - - if settings::save_global(my_id_i64, settings_value).is_err() { - return error_response(cv, CommunicationType::ErrorInvalidData); - } - - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsSave) - .with_receiver(my_id) - .with_request_id(cv); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - response -} - -pub fn handle_global_settings_load(cv: &CommunicationValue) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Ok(settings_value) = settings::load_global(my_id_i64) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(settings_value_str) = settings_value else { - let mut response = CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Path, - DataValue::Str("global.settings".to_string()), - ); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - return response; - }; - - let mut response = CommunicationValue::new(CommunicationType::GlobalSettingsLoad) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)); - - if let Some(session_id) = cv.get_data(DataType::SessionId).as_number() { - response = response.add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - response -} - -pub fn handle_settings_save( - cv: &CommunicationValue, - _expected_session_id: i128, -) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - }; - if session_id == 0 || session_id > 1_000_000 { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - }; - let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings payload".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - }; - - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - let Ok(session_id_i64) = i64::try_from(session_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - - if settings::save(my_id_i64, session_id_i64, settings_name, settings_value).is_err() { - return error_response(cv, CommunicationType::ErrorInvalidData); - } - - CommunicationValue::new(CommunicationType::SettingsSave) - .with_receiver(my_id) - .with_request_id(cv) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) -} - -pub fn handle_settings_load( - cv: &CommunicationValue, - _expected_session_id: i128, -) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - }; - if session_id == 0 || session_id > 1_000_000 { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - let Ok(session_id_i64) = i64::try_from(session_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing settings_name".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - }; - - if !settings_name - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.') - || settings_name.contains("..") - { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid settings_name".to_string()), - ) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - - let Ok(settings_value) = settings::load(my_id_i64, session_id_i64, settings_name) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(settings_value_str) = settings_value else { - return CommunicationValue::new(CommunicationType::ErrorNotFound) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - }; - - CommunicationValue::new(CommunicationType::SettingsLoad) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default(DataType::Payload, DataValue::Str(settings_value_str)) - .add_typed_default( - DataType::SettingsName, - DataValue::Str(settings_name.to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) -} - -pub fn handle_settings_list( - cv: &CommunicationValue, - _expected_session_id: i128, -) -> CommunicationValue { - let my_id = match cv.require_sender() { - Ok(my_id) => my_id, - Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let Ok(my_id_i64) = i64::try_from(my_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(session_id) = cv.get_data(DataType::SessionId).as_number() else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Missing session_id".to_string()), - ); - }; - if session_id == 0 || session_id > 1_000_000 { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default( - DataType::Message, - DataValue::Str("Invalid session_id".to_string()), - ) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ); - } - let Ok(session_id_i64) = i64::try_from(session_id) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - - let Ok(settings) = settings::list(my_id_i64, session_id_i64) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let settings_json = settings.into_iter().map(DataValue::Str).collect(); - CommunicationValue::new(CommunicationType::SettingsList) - .with_request_id(cv) - .with_receiver(my_id) - .add_typed_default(DataType::Settings, DataValue::Array(settings_json)) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) -} - -fn setting_response( - cv: &CommunicationValue, - response_type: CommunicationType, - setting: &SyncedSetting, -) -> CommunicationValue { - CommunicationValue::new(response_type) - .with_request_id(cv) - .with_receiver(sender_wire_id(setting.user_id)) - .add_typed_default( - DataType::SettingId, - DataValue::SignedNumber(setting.id.into()), - ) - .add_typed_default( - DataType::SettingScope, - DataValue::Str(setting.scope.as_str().to_string()), - ) - .add_typed_default( - DataType::SettingTarget, - DataValue::Str(setting.scope_key.clone()), - ) - .add_typed_default(DataType::SettingsName, DataValue::Str(setting.name.clone())) - .add_typed_default(DataType::Payload, DataValue::Str(setting.payload.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(setting.revision.into()), - ) -} - -fn blob_value(blob: &UserBlob) -> DataValue { - typed_container(vec![ - (DataType::BlobId, DataValue::Str(blob.blob_id.clone())), - (DataType::Blob, DataValue::Bytes(blob.blob.clone())), - ( - DataType::VersionNumber, - DataValue::SignedNumber(blob.revision.into()), - ), - ( - DataType::UpdatedAt, - DataValue::SignedNumber(blob.updated_at.into()), - ), - ]) -} - -fn blob_response( - cv: &CommunicationValue, - ty: CommunicationType, - blob: &UserBlob, -) -> CommunicationValue { - CommunicationValue::new(ty) - .with_request_id(cv) - .with_receiver(sender_wire_id(blob.user_id)) - .add_typed_default(DataType::BlobId, DataValue::Str(blob.blob_id.clone())) - .add_typed_default(DataType::Blob, DataValue::Bytes(blob.blob.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(blob.revision.into()), - ) - .add_typed_default( - DataType::UpdatedAt, - DataValue::SignedNumber(blob.updated_at.into()), - ) -} - -fn blob_changed(user_id: i64, blob_id: String, revision: i64, deleted: bool) -> CommunicationValue { - CommunicationValue::new(CommunicationType::UserBlobChanged) - .with_id(next_notification_id()) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default(DataType::BlobId, DataValue::Str(blob_id)) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(revision.into()), - ) - .add_typed_default(DataType::Deleted, DataValue::Bool(deleted)) -} - -fn blob_request(cv: &CommunicationValue) -> Result<(i64, String), CommunicationValue> { - let user_id = required_sender_id(cv)?; - if user_id <= 0 { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - } - let id = cv - .get_data(DataType::BlobId) - .and_then(DataValue::as_str) - .filter(|id| !id.is_empty()) - .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; - Ok((user_id, id.to_owned())) -} - -fn setting_changed(setting: &SyncedSetting) -> CommunicationValue { - CommunicationValue::new(CommunicationType::SyncedSettingChanged) - .with_receiver(sender_wire_id(setting.user_id)) - .add_typed_default( - DataType::SettingId, - DataValue::SignedNumber(setting.id.into()), - ) - .add_typed_default( - DataType::SettingScope, - DataValue::Str(setting.scope.as_str().to_string()), - ) - .add_typed_default( - DataType::SettingTarget, - DataValue::Str(setting.scope_key.clone()), - ) - .add_typed_default(DataType::SettingsName, DataValue::Str(setting.name.clone())) - .add_typed_default(DataType::Payload, DataValue::Str(setting.payload.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(setting.revision.into()), - ) -} - -fn setting_deleted(user_id: i64, deleted: &synced_settings::DeletedSetting) -> CommunicationValue { - CommunicationValue::new(CommunicationType::SyncedSettingChanged) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::DeletedSettingIds, - DataValue::Array(vec![DataValue::SignedNumber(deleted.id.into())]), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(deleted.revision.into()), - ) -} - -fn parse_setting_locator(cv: &CommunicationValue) -> Result { - let Some(scope_name) = cv.get_data(DataType::SettingScope).as_str() else { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - }; - let Some(scope) = SettingScope::parse(scope_name) else { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - }; - let Some(scope_key) = cv.get_data(DataType::SettingTarget).as_str() else { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - }; - let Some(name) = cv.get_data(DataType::SettingsName).as_str() else { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - }; - if !synced_settings::is_valid_name(name) { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - } - match scope { - SettingScope::User if !scope_key.is_empty() => { - Err(error_response(cv, CommunicationType::ErrorInvalidData)) - } - SettingScope::Contact if !scope_key.parse::().is_ok_and(|id| id > 0) => { - Err(error_response(cv, CommunicationType::ErrorInvalidData)) - } - SettingScope::Community if scope_key.is_empty() => { - Err(error_response(cv, CommunicationType::ErrorInvalidData)) - } - _ => Ok(SettingLocator { - scope, - scope_key: scope_key.to_string(), - name: name.to_string(), - }), - } -} - -fn validate_setting_target( - user_id: i64, - locator: &SettingLocator, -) -> Result<(), CommunicationType> { - match locator.scope { - SettingScope::User => Ok(()), - SettingScope::Contact => { - let contact_id = locator - .scope_key - .parse::() - .map_err(|_| CommunicationType::ErrorInvalidData)?; - match has_user(user_id, contact_id) { - Ok(true) => Ok(()), - Ok(false) => Err(CommunicationType::ErrorInvalidData), - Err(_) => Err(CommunicationType::ErrorInternal), - } - } - SettingScope::Community => { - match CommunitiesUtil::has_community(user_id, &locator.scope_key) { - Ok(true) => Ok(()), - Ok(false) => Err(CommunicationType::ErrorInvalidData), - Err(_) => Err(CommunicationType::ErrorInternal), - } - } - } -} - -fn setting_mutation_error( - cv: &CommunicationValue, - error_type: CommunicationType, -) -> SettingMutation { - SettingMutation { - response: error_response(cv, error_type), - changed: None, - } -} - -pub fn handle_synced_setting_set(cv: &CommunicationValue) -> SettingMutation { - let user_id = match required_sender_id(cv) { - Ok(user_id) if user_id > 0 => user_id, - _ => return setting_mutation_error(cv, CommunicationType::ErrorInvalidData), - }; - let locator = match parse_setting_locator(cv) { - Ok(locator) => locator, - Err(response) => { - return SettingMutation { - response, - changed: None, - }; - } - }; - if let Err(error_type) = validate_setting_target(user_id, &locator) { - return setting_mutation_error(cv, error_type); - } - let Some(payload) = cv.get_data(DataType::Payload).as_str() else { - return setting_mutation_error(cv, CommunicationType::ErrorInvalidData); - }; - match synced_settings::set( - user_id, - locator.scope, - &locator.scope_key, - &locator.name, - payload, - ) { - Ok(setting) => SettingMutation { - response: setting_response(cv, CommunicationType::SyncedSettingSet, &setting), - changed: Some(setting_changed(&setting)), - }, - Err(_) => setting_mutation_error(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_synced_setting_get(cv: &CommunicationValue) -> CommunicationValue { - let user_id = match required_sender_id(cv) { - Ok(user_id) if user_id > 0 => user_id, - _ => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - let locator = match parse_setting_locator(cv) { - Ok(locator) => locator, - Err(response) => return response, - }; - if let Err(error_type) = validate_setting_target(user_id, &locator) { - return error_response(cv, error_type); - } - match synced_settings::get(user_id, locator.scope, &locator.scope_key, &locator.name) { - Ok(Some(setting)) => setting_response(cv, CommunicationType::SyncedSettingGet, &setting), - Ok(None) => error_response(cv, CommunicationType::ErrorNotFound), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_synced_setting_delete(cv: &CommunicationValue) -> SettingMutation { - let user_id = match required_sender_id(cv) { - Ok(user_id) if user_id > 0 => user_id, - _ => return setting_mutation_error(cv, CommunicationType::ErrorInvalidData), - }; - let locator = match parse_setting_locator(cv) { - Ok(locator) => locator, - Err(response) => { - return SettingMutation { - response, - changed: None, - }; - } - }; - if let Err(error_type) = validate_setting_target(user_id, &locator) { - return setting_mutation_error(cv, error_type); - } - match synced_settings::delete(user_id, locator.scope, &locator.scope_key, &locator.name) { - Ok(Some(deleted)) => SettingMutation { - response: CommunicationValue::new(CommunicationType::SyncedSettingDelete) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::SettingId, - DataValue::SignedNumber(deleted.id.into()), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(deleted.revision.into()), - ), - changed: deleted.changed.then(|| setting_deleted(user_id, &deleted)), - }, - Ok(None) => SettingMutation { - response: CommunicationValue::new(CommunicationType::SyncedSettingDelete) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)), - changed: None, - }, - Err(_) => setting_mutation_error(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_synced_settings_list(cv: &CommunicationValue) -> CommunicationValue { - let user_id = match required_sender_id(cv) { - Ok(user_id) if user_id > 0 => user_id, - _ => return error_response(cv, CommunicationType::ErrorInvalidData), - }; - match synced_settings::list(user_id) { - Ok(settings) => CommunicationValue::new(CommunicationType::SyncedSettingsList) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::Settings, - DataValue::Array(settings.iter().map(synced_setting_value).collect()), - ), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_user_blob_put(cv: &CommunicationValue) -> BlobMutation { - let Ok((user_id, blob_id)) = blob_request(cv) else { - return BlobMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let Some(blob) = cv.get_data(DataType::Blob).and_then(DataValue::as_bytes) else { - return BlobMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let expected_revision = data_i64(cv, DataType::ExpectedRevision); - match user_blobs::put(user_id, &blob_id, &blob, expected_revision) { - Ok(stored) => BlobMutation { - response: blob_response(cv, CommunicationType::UserBlobPut, &stored), - changed: Some(blob_changed( - user_id, - stored.blob_id.clone(), - stored.revision, - false, - )), - }, - Err(iota_storage::storage_error::StorageError::RevisionConflict) => BlobMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }, - Err(_) => BlobMutation { - response: error_response(cv, CommunicationType::ErrorInternal), - changed: None, - }, - } -} - -pub fn handle_user_blob_get(cv: &CommunicationValue) -> CommunicationValue { - let Ok((user_id, blob_id)) = blob_request(cv) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - match user_blobs::get(user_id, &blob_id) { - Ok(Some(blob)) => blob_response(cv, CommunicationType::UserBlobGet, &blob), - Ok(None) => error_response(cv, CommunicationType::ErrorNotFound), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_user_blob_delete(cv: &CommunicationValue) -> BlobMutation { - let Ok((user_id, blob_id)) = blob_request(cv) else { - return BlobMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - match user_blobs::delete(user_id, &blob_id, data_i64(cv, DataType::ExpectedRevision)) { - Ok(Some(deleted)) => BlobMutation { - response: CommunicationValue::new(CommunicationType::UserBlobDelete) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default(DataType::BlobId, DataValue::Str(blob_id.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(deleted.revision.into()), - ), - changed: deleted - .changed - .then(|| blob_changed(user_id, blob_id, deleted.revision, true)), - }, - Ok(None) => BlobMutation { - response: CommunicationValue::new(CommunicationType::UserBlobDelete) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)), - changed: None, - }, - Err(iota_storage::storage_error::StorageError::RevisionConflict) => BlobMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }, - Err(_) => BlobMutation { - response: error_response(cv, CommunicationType::ErrorInternal), - changed: None, - }, - } -} - -pub fn handle_user_blob_list(cv: &CommunicationValue) -> CommunicationValue { - let Ok(user_id) = required_sender_id(cv) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - if user_id <= 0 { - return error_response(cv, CommunicationType::ErrorInvalidData); - } - match user_blobs::list(user_id) { - Ok(blobs) => CommunicationValue::new(CommunicationType::UserBlobList) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::Blobs, - DataValue::Array(blobs.iter().map(blob_value).collect()), - ), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -fn authenticated_user(cv: &CommunicationValue) -> Result { - let user_id = required_sender_id(cv)?; - if user_id <= 0 { - return Err(error_response(cv, CommunicationType::ErrorInvalidData)); - } - Ok(user_id) -} - -pub fn handle_user_block(cv: &CommunicationValue) -> PolicyMutation { - let Ok(user_id) = authenticated_user(cv) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let Some(blocked_user_id) = data_i64(cv, DataType::BlockedUserId).filter(|id| *id > 0) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - match blocked_users::block(user_id, blocked_user_id) { - Ok(record) => PolicyMutation { - response: CommunicationValue::new(CommunicationType::UserBlock) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::BlockedUserId, - DataValue::SignedNumber(blocked_user_id.into()), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.revision.into()), - ), - changed: Some( - CommunicationValue::new(CommunicationType::BlockedUsersChanged) - .with_id(next_notification_id()) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::BlockedUserId, - DataValue::SignedNumber(blocked_user_id.into()), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.revision.into()), - ) - .add_typed_default(DataType::Deleted, DataValue::Bool(record.deleted)), - ), - }, - Err(_) => PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInternal), - changed: None, - }, - } -} - -pub fn handle_user_unblock(cv: &CommunicationValue) -> PolicyMutation { - let Ok(user_id) = authenticated_user(cv) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let Some(blocked_user_id) = data_i64(cv, DataType::BlockedUserId).filter(|id| *id > 0) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - match blocked_users::unblock(user_id, blocked_user_id) { - Ok(mutation) => PolicyMutation { - response: CommunicationValue::new(CommunicationType::UserUnblock) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::BlockedUserId, - DataValue::SignedNumber(blocked_user_id.into()), - ), - changed: mutation.map(|mutation| { - CommunicationValue::new(CommunicationType::BlockedUsersChanged) - .with_id(next_notification_id()) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::BlockedUserId, - DataValue::SignedNumber(blocked_user_id.into()), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(mutation.revision.into()), - ) - .add_typed_default(DataType::Deleted, DataValue::Bool(mutation.deleted)) - }), - }, - Err(_) => PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInternal), - changed: None, - }, - } -} - -pub fn handle_blocked_users_get(cv: &CommunicationValue) -> CommunicationValue { - let Ok(user_id) = authenticated_user(cv) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - match blocked_users::list(user_id) { - Ok(users) => CommunicationValue::new(CommunicationType::BlockedUsersGet) - .with_request_id(cv) - .with_receiver(sender_wire_id(user_id)) - .add_typed_default( - DataType::BlockedUserIds, - DataValue::Array( - users - .into_iter() - .map(|id| DataValue::SignedNumber(id.into())) - .collect(), - ), - ), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -fn receipt_response( - cv: &CommunicationValue, - ty: CommunicationType, - policy: receipt_policy::ReceiptPolicy, -) -> CommunicationValue { - CommunicationValue::new(ty) - .with_request_id(cv) - .with_receiver(sender_wire_id(policy.user_id)) - .add_typed_default( - DataType::SendReadReceipts, - DataValue::Bool(policy.send_read_receipts), - ) - .add_typed_default( - DataType::SendReceivedReceipts, - DataValue::Bool(policy.send_received_receipts), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(policy.revision.into()), - ) -} -pub fn handle_receipt_policy_get(cv: &CommunicationValue) -> CommunicationValue { - let Ok(user_id) = authenticated_user(cv) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - match receipt_policy::get(user_id) { - Ok(policy) => receipt_response(cv, CommunicationType::ReceiptPolicyGet, policy), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} -pub fn handle_receipt_policy_set(cv: &CommunicationValue) -> PolicyMutation { - let Ok(user_id) = authenticated_user(cv) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let (Some(read), Some(received)) = ( - cv.get_data(DataType::SendReadReceipts) - .and_then(DataValue::as_bool), - cv.get_data(DataType::SendReceivedReceipts) - .and_then(DataValue::as_bool), - ) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - match receipt_policy::set(user_id, read, received) { - Ok(policy) => { - let response = receipt_response(cv, CommunicationType::ReceiptPolicySet, policy); - let changed = receipt_response(cv, CommunicationType::ReceiptPolicyChanged, policy) - .with_id(next_notification_id()); - PolicyMutation { - response, - changed: Some(changed), - } - } - Err(_) => PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInternal), - changed: None, - }, - } -} - -fn storage_policy_response( - cv: &CommunicationValue, - ty: CommunicationType, - policy: message_storage_policy::MessageStoragePolicy, -) -> CommunicationValue { - let (history_mode, duration) = match policy.history_mode { - message_storage_policy::MessageHistoryMode::Retain => ("retain", None), - message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery => { - ("delete_after_client_delivery", None) - } - }; - let retention_duration = match policy.retention { - message_storage_policy::MessageRetention::Forever => None, - message_storage_policy::MessageRetention::Duration { duration_ms } => Some(duration_ms), - }; - let mut response = CommunicationValue::new(ty) - .with_request_id(cv) - .with_receiver(sender_wire_id(policy.user_id)) - .add_typed_default( - DataType::MessageHistoryMode, - DataValue::Str(history_mode.into()), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(policy.revision.into()), - ); - if let Some(duration_ms) = retention_duration.or(duration) { - response = response.add_typed_default( - DataType::MessageRetentionDuration, - DataValue::SignedNumber(duration_ms.into()), - ); - } - response -} - -pub fn handle_message_storage_policy_get(cv: &CommunicationValue) -> CommunicationValue { - let Ok(user_id) = authenticated_user(cv) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - match message_storage_policy::get(user_id) { - Ok(policy) => { - storage_policy_response(cv, CommunicationType::MessageStoragePolicyGet, policy) - } - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -pub fn handle_message_storage_policy_set(cv: &CommunicationValue) -> PolicyMutation { - let Ok(user_id) = authenticated_user(cv) else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let Some(history_mode) = cv - .get_data(DataType::MessageHistoryMode) - .and_then(DataValue::as_str) - else { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - }; - let history_mode = match history_mode { - "retain" => message_storage_policy::MessageHistoryMode::Retain, - "delete_after_client_delivery" => { - message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery - } - _ => { - return PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }; - } - }; - let retention = match data_i64(cv, DataType::MessageRetentionDuration) { - Some(duration_ms) => message_storage_policy::MessageRetention::Duration { duration_ms }, - None => message_storage_policy::MessageRetention::Forever, - }; - match message_storage_policy::set(user_id, history_mode, retention) { - Ok(policy) => { - let response = - storage_policy_response(cv, CommunicationType::MessageStoragePolicySet, policy); - let changed = - storage_policy_response(cv, CommunicationType::MessageStoragePolicyChanged, policy) - .with_id(next_notification_id()); - PolicyMutation { - response, - changed: Some(changed), - } - } - Err(_) => PolicyMutation { - response: error_response(cv, CommunicationType::ErrorInvalidData), - changed: None, - }, - } -} - -/* This request is issued over Omikron's trusted Iota connection. Clients have - * no route to it, preventing arbitrary block-relationship disclosure. */ -pub fn handle_user_block_check(cv: &CommunicationValue) -> CommunicationValue { - let Some(sender_id) = data_i64(cv, DataType::SenderId).filter(|id| *id > 0) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - let Some(receiver_id) = data_i64(cv, DataType::ReceiverId).filter(|id| *id > 0) else { - return error_response(cv, CommunicationType::ErrorInvalidData); - }; - match blocked_users::is_blocked(receiver_id, sender_id) { - Ok(blocked) => CommunicationValue::new(CommunicationType::UserBlockCheck) - .with_request_id(cv) - .add_typed_default(DataType::IsBlocked, DataValue::Bool(blocked)), - Err(_) => error_response(cv, CommunicationType::ErrorInternal), - } -} - -#[cfg(test)] -mod synced_settings_tests { - use super::{handle_synced_setting_get, handle_synced_setting_set, parse_setting_locator}; - use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - - fn request() -> CommunicationValue { - CommunicationValue::new(CommunicationType::SyncedSettingSet) - .with_id(1) - .with_sender(7) - .add_typed_default(DataType::SettingScope, DataValue::Str("user".to_string())) - .add_typed_default(DataType::SettingTarget, DataValue::Str(String::new())) - .add_typed_default( - DataType::SettingsName, - DataValue::Str("notifications.enabled".to_string()), - ) - } - - #[test] - fn missing_sender_is_rejected_for_synced_settings() { - let response = handle_synced_setting_get(&request().without_sender()); - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - assert_eq!(response.id(), Some(1)); - assert_eq!(response.receiver(), None); - } - - #[test] - fn user_scope_rejects_a_non_empty_target() { - let request = - request().add_typed_default(DataType::SettingTarget, DataValue::Str("123".to_string())); - - let response = handle_synced_setting_set(&request).response; - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - } - - #[test] - fn contact_scope_rejects_a_malformed_target() { - let request = request() - .add_typed_default( - DataType::SettingScope, - DataValue::Str("contact".to_string()), - ) - .add_typed_default( - DataType::SettingTarget, - DataValue::Str("not-a-user".to_string()), - ); - - let response = handle_synced_setting_set(&request).response; - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - } - - #[test] - fn community_scope_requires_an_address() { - let request = request() - .add_typed_default( - DataType::SettingScope, - DataValue::Str("community".to_string()), - ) - .add_typed_default(DataType::SettingTarget, DataValue::Str(String::new())); - - let response = handle_synced_setting_set(&request).response; - - assert!(response.is_type(CommunicationType::ErrorInvalidData)); - } - - #[test] - fn invalid_setting_name_is_rejected() { - let request = request().add_typed_default( - DataType::SettingsName, - DataValue::Str("notifications..enabled".to_string()), - ); - - let response = parse_setting_locator(&request); - - assert!(response.is_err()); - } -} diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs deleted file mode 100644 index 8dde6c3..0000000 --- a/iota-connection/src/relay.rs +++ /dev/null @@ -1,383 +0,0 @@ -use iota_util::route_target::RouteTarget; -use mtp::codec::{ - CommunicationValue, ProtectionPolicy, RelayError, RelayOpenOptions, SignaturePolicy, TypeMap, - VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, - open_relay_content_with_limits_without_replay, open_relay_metadata_with_without_replay, - relay_metadata_claimed_signer_id_with_options, -}; -use mtp::crypto::{Keyring, PublicKeyBundle}; -use std::fmt; - -pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy { - signature: SignaturePolicy::Dual, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageSecurityClass { - RelayOnly, - AuthenticatedPeerControl, - AuthenticatedLocalRequest, -} - -pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass { - const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[ - mtp::codec::CommunicationType::MessageSend, - mtp::codec::CommunicationType::MessageLive, - mtp::codec::CommunicationType::MessageState, - mtp::codec::CommunicationType::MessageEdit, - mtp::codec::CommunicationType::MessageEditLive, - mtp::codec::CommunicationType::MessageReactionAdd, - mtp::codec::CommunicationType::MessageReactionRemove, - mtp::codec::CommunicationType::MessageReactionLive, - mtp::codec::CommunicationType::MessageDelete, - mtp::codec::CommunicationType::MessageDeleteLive, - mtp::codec::CommunicationType::MessageOtherIota, - mtp::codec::CommunicationType::SetChatSecret, - mtp::codec::CommunicationType::SendChat, - mtp::codec::CommunicationType::SettingsSave, - mtp::codec::CommunicationType::GlobalSettingsSave, - mtp::codec::CommunicationType::AddConversation, - mtp::codec::CommunicationType::AddCommunity, - mtp::codec::CommunicationType::RemoveCommunity, - ]; - - if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) { - MessageSecurityClass::RelayOnly - } else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret) - || frame.is_type(mtp::codec::CommunicationType::MessageGet) - || frame.is_type(mtp::codec::CommunicationType::MessagesGet) - { - MessageSecurityClass::AuthenticatedPeerControl - } else { - MessageSecurityClass::AuthenticatedLocalRequest - } -} - -#[cfg(test)] -mod security_tests { - use super::{MessageSecurityClass, message_security_class}; - use mtp::codec::{CommunicationType, CommunicationValue}; - - #[test] - fn synchronized_setting_requests_are_authenticated_local_requests() { - for setting_type in [ - CommunicationType::SyncedSettingSet, - CommunicationType::SyncedSettingGet, - CommunicationType::SyncedSettingDelete, - CommunicationType::SyncedSettingsList, - CommunicationType::SyncedSettingChanged, - ] { - assert_eq!( - message_security_class(&CommunicationValue::new(setting_type)), - MessageSecurityClass::AuthenticatedLocalRequest - ); - } - } -} - -#[derive(Debug, Clone)] -pub struct UserIdentity { - pub user_id: u64, - pub iota_id: u64, - pub signing_keys: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VerifiedRelayContext { - pub signer_id: u64, - pub final_recipient_id: u64, - pub message_id: String, - pub created_at: u64, - pub type_map: TypeMap, -} - -#[derive(Debug, Clone)] -pub struct VerifiedRelay { - pub metadata: VerifiedRelayMetadata, - pub context: VerifiedRelayContext, - pub signing_keys: Vec, -} - -#[derive(Debug)] -pub enum RelayValidationError { - WrongNextHop { expected: u64, actual: Option }, - OuterSenderNotAllowed, - MissingSigningKeys(u64), - MissingTypeMap, - InvalidRouteTarget(u64), - KeyLookup(String), - Relay(RelayError), -} - -impl fmt::Display for RelayValidationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::WrongNextHop { expected, actual } => { - write!( - formatter, - "relay next hop {:?} does not match Iota {expected}", - actual - ) - } - Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"), - Self::MissingSigningKeys(signer_id) => { - write!(formatter, "no trusted signing keys for user {signer_id}") - } - Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"), - Self::InvalidRouteTarget(target) => { - write!(formatter, "relay has invalid route target {target}") - } - Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"), - Self::Relay(error) => error.fmt(formatter), - } - } -} - -impl std::error::Error for RelayValidationError {} - -impl From for RelayValidationError { - fn from(error: RelayError) -> Self { - Self::Relay(error) - } -} - -/* - * Relay metadata is opened only after the claimed signer selects trusted key - * history. Replay reservation happens after verification and durable - * acceptance, so a failed delivery can be retried without losing the frame. - */ -pub async fn verify_relay_metadata( - frame: &CommunicationValue, - local_iota_id: u64, - keyring: &Keyring, - resolve_signing_keys: F, -) -> Result -where - F: FnOnce(u64) -> Fut, - Fut: Future, RelayValidationError>>, -{ - let expected_next_hop = RouteTarget::Iota(local_iota_id) - .wire_id() - .ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?; - if frame.receiver() != Some(expected_next_hop) { - return Err(RelayValidationError::WrongNextHop { - expected: expected_next_hop, - actual: frame.receiver(), - }); - } - if frame.sender().is_some() { - return Err(RelayValidationError::OuterSenderNotAllowed); - } - - let open_options = RelayOpenOptions::new(RELAY_PROTECTION_POLICY); - let claimed_signer = relay_metadata_claimed_signer_id_with_options( - frame, - &[keyring], - open_options.decode_limits, - open_options.protected_limits, - )?; - let signing_keys = resolve_signing_keys(claimed_signer).await?; - if signing_keys.is_empty() { - return Err(RelayValidationError::MissingSigningKeys(claimed_signer)); - } - - let resolver_keys = signing_keys.clone(); - let type_map = frame - .type_map() - .cloned() - .ok_or(RelayValidationError::MissingTypeMap)?; - let metadata = open_relay_metadata_with_without_replay( - frame, - &[keyring], - Some(claimed_signer), - move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()), - RelayOpenOptions::new(RELAY_PROTECTION_POLICY), - )?; - - let context = VerifiedRelayContext { - signer_id: metadata.signer_id(), - final_recipient_id: metadata.final_recipient_id(), - message_id: metadata.message_id().to_owned(), - created_at: metadata.created_at(), - type_map, - }; - - Ok(VerifiedRelay { - metadata, - context, - signing_keys, - }) -} - -pub fn open_verified_relay_content( - relay: &VerifiedRelay, - keyrings: &[&Keyring], - expected_recipient_id: u64, -) -> Result { - Ok(open_relay_content_with_limits_without_replay( - &relay.metadata, - keyrings, - &relay.signing_keys, - Some(expected_recipient_id), - RelayOpenOptions { - policy: RELAY_PROTECTION_POLICY, - decode_limits: relay.metadata.decode_limits(), - encode_limits: relay.metadata.encode_limits(), - protected_limits: relay.metadata.protected_limits(), - }, - )?) -} - -pub fn forward_verified_relay( - frame: &CommunicationValue, - target: RouteTarget, -) -> Result { - let next_hop_id = target - .wire_id() - .ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?; - Ok(forward_relay_frame(frame, next_hop_id)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use mtp::codec::SealedRelayBuilder; - use mtp::crypto::{DualSigner, Ed25519Signer, Keyring}; - - fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> { - let signer_keyring = Keyring::generate(); - let recipient_keyring = Keyring::generate(); - let signer = DualSigner::new( - &signer_keyring.sig_cl_secret_key, - &signer_keyring.sig_pq_secret_key, - &signer_keyring.sig_pq_public_key, - ) - .map_err(|error| error.to_string())?; - let frame = SealedRelayBuilder::new( - "MessageSend", - mtp::codec::DataValue::Str("payload".into()), - 7, - 42, - RouteTarget::Iota(99) - .wire_id() - .ok_or("invalid test target")?, - &signer, - ) - .message_id(message_id) - .created_at(123) - .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) - .content_recipients(vec![recipient_keyring.public_key_bundle()]) - .build() - .map_err(|error| error.to_string())?; - Ok((signer_keyring, recipient_keyring, frame)) - } - - #[tokio::test] - async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> { - let (signer, recipient, frame) = relay("accepted")?; - let trusted_key = signer.public_key_bundle(); - let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move { - (signer_id == 7) - .then_some(vec![trusted_key]) - .ok_or(RelayValidationError::MissingSigningKeys(signer_id)) - }) - .await - .map_err(|error| error.to_string())?; - - assert_eq!(verified.context.signer_id, 7); - assert_eq!(verified.context.final_recipient_id, 42); - assert_eq!(verified.context.message_id, "accepted"); - Ok(()) - } - - #[tokio::test] - async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> { - let (_signer, recipient, frame) = relay("wrong-key")?; - let wrong_signer = Keyring::generate(); - let trusted_key = wrong_signer.public_key_bundle(); - let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { - Ok(vec![trusted_key]) - }) - .await; - - assert!(matches!(result, Err(RelayValidationError::Relay(_)))); - Ok(()) - } - - #[tokio::test] - async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> { - let signer_keyring = Keyring::generate(); - let recipient_keyring = Keyring::generate(); - let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key) - .map_err(|error| error.to_string())?; - let frame = SealedRelayBuilder::new( - "MessageSend", - mtp::codec::DataValue::Str("payload".into()), - 7, - 42, - RouteTarget::Iota(99) - .wire_id() - .ok_or("invalid test target")?, - &signer, - ) - .message_id("classical-only") - .created_at(123) - .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) - .content_recipients(vec![recipient_keyring.public_key_bundle()]) - .build() - .map_err(|error| error.to_string())?; - let trusted_key = signer_keyring.public_key_bundle(); - let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move { - Ok(vec![trusted_key]) - }) - .await; - - assert!(matches!(result, Err(RelayValidationError::Relay(_)))); - Ok(()) - } - - #[tokio::test] - async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> { - let (_signer, recipient, frame) = relay("outer-sender")?; - let frame = frame.with_sender(501); - let result = verify_relay_metadata(&frame, 99, &recipient, |_| async { - Err(RelayValidationError::MissingSigningKeys(7)) - }) - .await; - - assert!(matches!( - result, - Err(RelayValidationError::OuterSenderNotAllowed) - )); - Ok(()) - } - - #[tokio::test] - async fn verification_does_not_commit_replay_state() -> Result<(), String> { - let (signer, recipient, frame) = relay("duplicate")?; - let trusted_key = signer.public_key_bundle(); - - for _ in 0..2 { - let trusted_key = trusted_key.clone(); - let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { - Ok(vec![trusted_key]) - }) - .await; - let _ = result.map_err(|error| error.to_string())?; - } - Ok(()) - } - - #[test] - fn forwarding_preserves_sealed_payload() -> Result<(), String> { - let (_signer, _recipient, frame) = relay("forwarding")?; - let forwarded = forward_verified_relay(&frame, RouteTarget::User(100)) - .map_err(|error| error.to_string())?; - - assert_eq!(frame.sender(), None); - assert_eq!(forwarded.sender(), None); - assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id()); - assert_eq!(frame.payload(), forwarded.payload()); - Ok(()) - } -} diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml deleted file mode 100644 index f28cc45..0000000 --- a/iota-core/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "iota-core" -version = "0.1.0" -edition = "2024" -autobins = false - -[dependencies] -iota-cli = { path = "../iota-cli" } -iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage" } -iota-terms = { path = "../iota-terms" } -iota-updater = { path = "../iota-updater" } -iota-util = { path = "../iota-util" } -iota-paths = { path = "../iota-paths" } -web-server = { path = "../web-server" } -pnet = "0.35.0" -tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-core/src/lib.rs b/iota-core/src/lib.rs deleted file mode 100644 index 2deef45..0000000 --- a/iota-core/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod consent_state; diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml deleted file mode 100644 index c9dc6eb..0000000 --- a/iota-daemon-lib/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "iota-daemon-lib" -version = "0.1.0" -edition = "2024" - -[dependencies] -async-trait = "0.1.89" -iota-ipc = { path = "../iota-ipc" } -iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage" } -iota-updater = { path = "../iota-updater" } -iota-util = { path = "../iota-util" } -omikron-connector = { path = "../omikron-connector" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } -libc = "0.2" -sysinfo = "0.38.0" -serde_yaml = "0.9" -serde_json = "1" -tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } -uuid = { version = "*", features = ["v4"] } - -[dev-dependencies] -tempfile = "3" diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs deleted file mode 100644 index 59a87f9..0000000 --- a/iota-daemon-lib/src/command_router.rs +++ /dev/null @@ -1,596 +0,0 @@ -use crate::log_buffer::LogBuffer; -use crate::{DaemonRuntime, DaemonServices}; -use iota_ipc::{ - CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent, - IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE, - OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, - TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, -}; -use iota_logger::{log, log_command}; -use iota_storage::users::pending_operations::{ - self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, -}; -use iota_storage::users::user_manager; -use iota_storage::util::config_util::{self}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::daemon_state::{ShutdownReason, StartupPhase}; - -pub use iota_ipc::IpcRole; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PeerContext { - pub pid: i32, - pub uid: u32, - pub role: IpcRole, -} - -const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512; - -fn now_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -fn bounded_log_entries(mut entries: Vec) -> Vec { - entries.truncate(MAX_LOG_ENTRIES_PER_RESPONSE); - while !entries.is_empty() { - let response = DaemonMessage::Response(ResponseEnvelope { - request_id: u64::MAX, - result: ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { - entries: entries.clone(), - })), - }); - let fits = serde_json::to_vec(&response) - .map(|encoded| encoded.len() <= MAX_MESSAGE_SIZE) - .unwrap_or(false); - if fits { - return entries; - } - entries.remove(0); - } - entries -} - -#[derive(Clone)] -pub struct CommandRouter { - runtime: Arc, - services: Arc, - log_buffer: Arc>, -} - -impl CommandRouter { - pub fn new( - runtime: Arc, - services: Arc, - log_buffer: Arc>, - ) -> Self { - Self { - runtime, - services, - log_buffer, - } - } - - pub async fn route( - &self, - peer: &PeerContext, - request_id: u64, - request: LocalRequest, - ) -> ResponseEnvelope { - if !peer.role.allows(request.required_role()) { - log!( - "IPC authorization denied: pid={}, uid={}, role={:?}, request={:?}", - peer.pid, - peer.uid, - peer.role, - request - ); - return ResponseEnvelope { - request_id, - result: ResponseResult::Error(IpcErrorCode::Unauthorized), - }; - } - - log_command!( - "pid={} uid={} role={:?} request={:?}", - peer.pid, - peer.uid, - peer.role, - request - ); - let result = self.execute(request).await; - ResponseEnvelope { request_id, result } - } - - async fn execute(&self, request: LocalRequest) -> ResponseResult { - if !self.services.active - && !matches!( - request, - LocalRequest::GetStatus | LocalRequest::GetDaemonStatus - ) - { - return ResponseResult::Error(IpcErrorCode::Unauthorized); - } - let needs_omikron = matches!( - request, - LocalRequest::CreateUser { .. } - | LocalRequest::AttachUserFromTu { .. } - | LocalRequest::ReleaseUser { .. } - | LocalRequest::CompleteDeleteUser { .. } - ); - if needs_omikron && !self.services.omikron.is_connected().await { - return ResponseResult::Error( - if self.runtime.current_startup_phase() != StartupPhase::Ready { - IpcErrorCode::NotReady - } else { - IpcErrorCode::OmikronUnavailable - }, - ); - } - match request { - LocalRequest::GetStatus => { - let phase = self.runtime.current_startup_phase(); - let degraded = self.runtime.degraded_reason.borrow().clone(); - let tasks: Vec = self - .runtime - .state - .active_tasks - .iter() - .map(|task| task.to_string()) - .collect(); - ResponseResult::Ok(ResponsePayload::Status(StatusResponse { - phase: format!("{:?}", phase), - tasks: tasks.clone(), - degraded_reason: degraded, - })) - } - LocalRequest::ListTasks => { - let tasks: Vec = self - .runtime - .state - .active_tasks - .iter() - .map(|task| TaskSummary { - name: task.to_string(), - }) - .collect(); - ResponseResult::Ok(ResponsePayload::Tasks(tasks)) - } - LocalRequest::ListUsers => { - let users = user_manager::get_residency() - .into_iter() - .map(|user| { - let profile = user_manager::get_user(user.user_id)?; - Ok(UserSummary { - credential_present: user.state == user_manager::LocalUserState::Managed - && profile.is_some_and(|profile| { - iota_util::file_util::read_user_credential_with_legacy( - user.user_id, - &profile.username, - ) - .ok() - .flatten() - .is_some() - }), - user_id: user.user_id, - username: user.username, - state: match user.state { - user_manager::LocalUserState::Managed => { - iota_ipc::LocalUserState::Managed - } - user_manager::LocalUserState::Released => { - iota_ipc::LocalUserState::Released - } - }, - data_present: user.data_present, - }) - }) - .collect::, iota_storage::storage_error::StorageError>>(); - let Ok(users) = users else { - return ResponseResult::Error(IpcErrorCode::StorageFailure); - }; - ResponseResult::Ok(ResponsePayload::Users(users)) - } - LocalRequest::CreateUser { username } => { - match omikron_connector::user_ops::create_user( - self.services.omikron.as_ref(), - &username, - ) - .await - { - Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated { - user_id: user.user_id, - username: user.username, - }), - Err(error) => { - log!("User creation failed: {error:?}"); - match error { - omikron_connector::user_ops::CreateUserError::InvalidUsername => { - ResponseResult::Error(IpcErrorCode::InvalidRequest) - } - omikron_connector::user_ops::CreateUserError::Transport( - omikron_connector::OmikronError::Timeout(_), - ) => ResponseResult::Error(IpcErrorCode::Timeout), - omikron_connector::user_ops::CreateUserError::Transport(_) => { - ResponseResult::Error(IpcErrorCode::OmikronUnavailable) - } - omikron_connector::user_ops::CreateUserError::RemoteRejected => { - ResponseResult::Error(IpcErrorCode::Conflict) - } - omikron_connector::user_ops::CreateUserError::LocalFinalizationPending { .. } => { - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => { - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - omikron_connector::user_ops::CreateUserError::InvalidResponse => { - ResponseResult::Error(IpcErrorCode::InternalFailure) - } - } - } - } - } - LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) - { - Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }), - Err(iota_storage::storage_error::StorageError::PendingRelayOwnershipUnknown) => { - log!( - "User data purge is waiting for pending relay ownership classification for {user_id}" - ); - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - Err(error) => { - log!("User data purge failed for {user_id}: {error}"); - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - }, - LocalRequest::AttachUserFromTu { credential } => { - match omikron_connector::user_ops::attach_user_from_tu( - self.services.omikron.as_ref(), - &credential.0, - ) - .await - { - Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Added {} ({}) to this Iota", user.username, user.user_id), - }), - Err(error) => { - log!("Credential attach failed: {error:?}"); - ResponseResult::Error(IpcErrorCode::Unauthorized) - } - } - } - LocalRequest::CompleteDeleteUser { - user_id, - credential, - } => { - let contents = match credential { - Some(value) => Ok(value.0), - None => user_manager::get_user(user_id) - .map_err(|_| ()) - .and_then(|user| user.ok_or(())) - .and_then(|user| { - iota_util::file_util::read_user_credential_with_legacy( - user_id, - &user.username, - ) - .map_err(|_| ()) - }) - .and_then(|value| value.ok_or(())), - }; - let Ok(contents) = contents else { - return ResponseResult::Error(IpcErrorCode::Unauthorized); - }; - match omikron_connector::user_ops::complete_delete_user_with_tu( - self.services.omikron.as_ref(), - &contents, - user_id, - ) - .await - { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Deleted Tensamin account {user_id}"), - }), - Err(error) => { - log!("Credential deletion failed for {user_id}: {error:?}"); - ResponseResult::Error(IpcErrorCode::Unauthorized) - } - } - } - LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), - LocalRequest::ReleaseUser { user_id } => { - let user = match user_manager::get_user(user_id) { - Ok(user) => user, - Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), - }; - let Some(user) = user else { - return ResponseResult::Error(IpcErrorCode::NotFound); - }; - if pending_operations::upsert(&PendingUserOperation { - user_id, - operation: PendingUserOperationKind::Release, - username: user.username, - public_key: None, - private_key_hash: None, - reset_token: None, - registration_token: None, - phase: PendingUserOperationPhase::Prepared, - created_at: now_millis(), - }) - .is_err() - { - return ResponseResult::Error(IpcErrorCode::StorageFailure); - } - let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); - match self - .services - .omikron - .await_response(&request, Duration::from_secs(20)) - .await - { - Ok(response) if response.is_type(CommunicationType::Success) => { - match user_manager::release_user(user_id) { - Ok(()) if pending_operations::remove(user_id).is_ok() => { - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!( - "Released user {user_id}; hosted data was retained" - ), - }) - } - Ok(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), - Err(error) => { - log!( - "Remote release succeeded but local cleanup failed for {user_id}: {error}" - ); - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - } - } - Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => { - let _ = pending_operations::remove(user_id); - ResponseResult::Error(IpcErrorCode::Unauthorized) - } - Ok(_) => { - let _ = pending_operations::remove(user_id); - ResponseResult::Error(IpcErrorCode::Conflict) - } - Err(omikron_connector::OmikronError::Timeout(_)) => { - ResponseResult::Error(IpcErrorCode::Timeout) - } - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), - } - } - LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Reconnected to Omikron server".into(), - }), - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), - }, - LocalRequest::RotateIotaIdentity => { - match self.services.omikron.rotate_identity().await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "New identity registered with Omikron".into(), - }), - Err(error) => { - log!("Iota identity rotation failed: {}", error); - ResponseResult::Error(IpcErrorCode::OmikronUnavailable) - } - } - } - LocalRequest::RequestProcessExit { intent } => { - if matches!(intent, ExitIntent::Restart) - && !matches!( - crate::deployment::from_environment().supervisor, - iota_ipc::SupervisorKind::Systemd | iota_ipc::SupervisorKind::IotaUi - ) - { - return ResponseResult::Error(IpcErrorCode::Conflict); - } - self.runtime.request_shutdown(match intent { - ExitIntent::Stop => ShutdownReason::Stop, - ExitIntent::Restart => ShutdownReason::Restart, - }); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "process exit accepted".into(), - }) - } - LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus( - iota_ipc::DaemonStatusResponse { - formatted: format!("{:?}", self.runtime.snapshot()), - }, - )), - LocalRequest::RestartDaemon => { - self.runtime.request_shutdown(ShutdownReason::Restart); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Daemon restart requested".into(), - }) - } - LocalRequest::StopDaemon => { - self.runtime.request_shutdown(ShutdownReason::Stop); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Daemon shutdown requested".into(), - }) - } - LocalRequest::GetConfig => { - let cfg = config_util::CONFIG.load(); - let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default(); - ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml })) - } - LocalRequest::SetConfig { key, value } => { - match config_util::modify_config_value(&key, &value) { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Set {key} = {value}"), - }), - Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest), - } - } - LocalRequest::ReloadConfig => { - config_util::load_config(); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Configuration reloaded".into(), - }) - } - LocalRequest::GetOmikronStatus => { - let connected = self.services.omikron.is_connected().await; - let iota_id = config_util::CONFIG.load().iota_id; - ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse { - connected, - iota_id, - })) - } - LocalRequest::ListComponents => { - let snapshot = self.runtime.snapshot(); - let components: Vec = snapshot - .components - .into_iter() - .map(|(id, health)| ComponentStatusResponse { - id, - status: health.status, - message: health.message, - }) - .collect(); - ResponseResult::Ok(ResponsePayload::Components(components)) - } - LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Ok(Some(user)) => { - let credential_present = - iota_util::file_util::read_user_credential_with_legacy( - user_id, - &user.username, - ) - .ok() - .flatten() - .is_some(); - ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - state: iota_ipc::LocalUserState::Managed, - data_present: user_manager::get_residency() - .iter() - .find(|entry| entry.user_id == user_id) - .is_none_or(|entry| entry.data_present), - credential_present, - })) - } - Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), - Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure), - }, - LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), - LocalRequest::GetLogs { limit } => { - let entries = if let Ok(buf) = self.log_buffer.lock() { - bounded_log_entries(buf.recent(limit.min(MAX_LOG_ENTRIES_PER_RESPONSE))) - } else { - Vec::new() - }; - ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) - } - LocalRequest::CheckUpdate => match iota_updater::check_update().await { - Ok(available) => { - ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse { - available, - })) - } - Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), - }, - LocalRequest::ListCommunities => { - let iota_id = config_util::CONFIG.load().iota_id; - let Ok(iota_id) = iota_id.map(i64::try_from).unwrap_or(Ok(0)) else { - return ResponseResult::Ok(ResponsePayload::Communities(Vec::new())); - }; - let stored = - iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); - let summaries: Vec = stored - .into_iter() - .map(|c| CommunitySummary { - name: c.address, - title: c.title, - }) - .collect(); - ResponseResult::Ok(ResponsePayload::Communities(summaries)) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::{IpcRole, LocalRequest, bounded_log_entries}; - use iota_ipc::{ExitIntent, LogEntry, SecretString}; - - #[test] - fn every_request_has_an_explicit_role_policy() { - let requests = [ - LocalRequest::GetStatus, - LocalRequest::ListTasks, - LocalRequest::ListUsers, - LocalRequest::CreateUser { - username: "alice".into(), - }, - LocalRequest::AttachUserFromTu { - credential: SecretString("credential".into()), - }, - LocalRequest::PurgeUserData { user_id: 1 }, - LocalRequest::ReleaseUser { user_id: 1 }, - LocalRequest::CompleteDeleteUser { - user_id: 1, - credential: None, - }, - LocalRequest::RemoveUser { user_id: 1 }, - LocalRequest::ReconnectOmikron, - LocalRequest::RotateIotaIdentity, - LocalRequest::RequestProcessExit { - intent: ExitIntent::Stop, - }, - LocalRequest::GetDaemonStatus, - LocalRequest::RestartDaemon, - LocalRequest::StopDaemon, - LocalRequest::GetConfig, - LocalRequest::SetConfig { - key: "port".into(), - value: "1984".into(), - }, - LocalRequest::ReloadConfig, - LocalRequest::GetOmikronStatus, - LocalRequest::ListComponents, - LocalRequest::GetUser { user_id: 1 }, - LocalRequest::ImportUser { - username: "alice".into(), - }, - LocalRequest::GetLogs { limit: 10 }, - LocalRequest::CheckUpdate, - LocalRequest::ListCommunities, - ]; - - assert_eq!(requests.len(), 25); - for request in requests { - let required = request.required_role(); - assert!(IpcRole::Admin.allows(required)); - assert_eq!( - IpcRole::Operate.allows(required), - required != IpcRole::Admin - ); - assert_eq!(IpcRole::Read.allows(required), required == IpcRole::Read); - } - } - - #[test] - fn log_responses_drop_entries_that_cannot_fit_one_ipc_frame() { - let entries = vec![LogEntry { - timestamp_ms: 0, - sender: "test".into(), - message: "x".repeat(2 * 1024 * 1024), - is_error: false, - }]; - - assert!(bounded_log_entries(entries).is_empty()); - } -} diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs deleted file mode 100644 index 634cbe2..0000000 --- a/iota-daemon-lib/src/daemon_state.rs +++ /dev/null @@ -1,305 +0,0 @@ -use crate::TaskRegistry; -use iota_ipc::StateSnapshot; -use iota_state::DaemonState; -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; -use sysinfo::{RefreshKind, System}; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; - -/// Reason the daemon is shutting down. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ShutdownReason { - Stop, - Restart, - Fatal(String), -} - -impl ShutdownReason { - pub fn exit_code(&self) -> i32 { - match self { - ShutdownReason::Stop => 0, - ShutdownReason::Restart => 75, - ShutdownReason::Fatal(_) => 1, - } - } -} - -/// Tracks the lifecycle phase of the daemon for IPC visibility. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StartupPhase { - Starting, - MigratingStorage, - LoadingUsers, - StartingServices, - Ready, - Degraded, - Stopping, -} - -impl From for iota_ipc::StartupPhase { - fn from(phase: StartupPhase) -> Self { - match phase { - StartupPhase::Starting => iota_ipc::StartupPhase::Starting, - StartupPhase::MigratingStorage => iota_ipc::StartupPhase::MigratingStorage, - StartupPhase::LoadingUsers => iota_ipc::StartupPhase::LoadingUsers, - StartupPhase::StartingServices => iota_ipc::StartupPhase::StartingServices, - StartupPhase::Ready => iota_ipc::StartupPhase::Ready, - StartupPhase::Degraded => iota_ipc::StartupPhase::Degraded, - StartupPhase::Stopping => iota_ipc::StartupPhase::Stopping, - } - } -} - -/* This wrapper exposes daemon state as IPC-safe snapshots while preserving a - * single owned state instance for all daemon subsystems. The cancellation token - * is the single lifecycle signal, and all subsystems check it instead of a - * separate boolean. */ -pub struct DaemonRuntime { - pub state: Arc, - pub cancellation: CancellationToken, - pub shutdown_tx: watch::Sender>, - shutdown_rx: watch::Receiver>, - pub startup_phase: watch::Sender, - pub degraded_reason: watch::Sender>, - startup_phase_rx: watch::Receiver, - degraded_reason_rx: watch::Receiver>, - pub lifecycle: watch::Sender, - pub startup_step: watch::Sender>, - pub components: watch::Sender>, - lifecycle_rx: watch::Receiver, - startup_step_rx: watch::Receiver>, - components_rx: watch::Receiver>, - pub tasks: TaskRegistry, -} - -impl Clone for DaemonRuntime { - fn clone(&self) -> Self { - Self { - state: self.state.clone(), - cancellation: self.cancellation.clone(), - shutdown_tx: self.shutdown_tx.clone(), - shutdown_rx: self.shutdown_rx.clone(), - startup_phase: self.startup_phase.clone(), - degraded_reason: self.degraded_reason.clone(), - startup_phase_rx: self.startup_phase_rx.clone(), - degraded_reason_rx: self.degraded_reason_rx.clone(), - lifecycle: self.lifecycle.clone(), - startup_step: self.startup_step.clone(), - components: self.components.clone(), - lifecycle_rx: self.lifecycle_rx.clone(), - startup_step_rx: self.startup_step_rx.clone(), - components_rx: self.components_rx.clone(), - tasks: self.tasks.clone(), - } - } -} - -impl Default for DaemonRuntime { - fn default() -> Self { - Self::new() - } -} - -impl DaemonRuntime { - pub fn new() -> Self { - let (shutdown_tx, shutdown_rx) = watch::channel(None); - let (startup_phase, startup_phase_rx) = watch::channel(StartupPhase::Starting); - let (degraded_reason, degraded_reason_rx) = watch::channel(None); - let (lifecycle, lifecycle_rx) = watch::channel(iota_ipc::LifecyclePhase::Starting); - let (startup_step, startup_step_rx) = watch::channel(Some("starting".to_string())); - let (components, components_rx) = watch::channel(BTreeMap::new()); - Self { - state: Arc::new(DaemonState::new()), - cancellation: CancellationToken::new(), - shutdown_tx, - shutdown_rx, - startup_phase, - degraded_reason, - startup_phase_rx, - degraded_reason_rx, - lifecycle, - startup_step, - components, - lifecycle_rx, - startup_step_rx, - components_rx, - tasks: TaskRegistry::default(), - } - } - - pub fn shutdown(&self, reason: ShutdownReason) { - self.request_shutdown(reason); - self.begin_shutdown(); - } - - pub fn request_shutdown(&self, reason: ShutdownReason) { - if self.shutdown_tx.borrow().is_none() { - let _ = self.shutdown_tx.send(Some(reason)); - } - } - - pub fn begin_shutdown(&self) { - self.cancellation.cancel(); - } - - pub fn shutdown_reason(&self) -> Option { - self.shutdown_tx.borrow().clone() - } - - pub fn is_shutting_down(&self) -> bool { - self.cancellation.is_cancelled() - } - - pub fn set_startup_phase(&self, phase: StartupPhase) { - let _ = self.startup_phase.send(phase); - let (lifecycle, step) = match phase { - StartupPhase::Ready => (iota_ipc::LifecyclePhase::Ready, None), - StartupPhase::Stopping => (iota_ipc::LifecyclePhase::Stopping, Some("stopping".into())), - StartupPhase::MigratingStorage => ( - iota_ipc::LifecyclePhase::Starting, - Some("migrating_storage".into()), - ), - StartupPhase::LoadingUsers => ( - iota_ipc::LifecyclePhase::Starting, - Some("loading_users".into()), - ), - StartupPhase::StartingServices => ( - iota_ipc::LifecyclePhase::Starting, - Some("starting_services".into()), - ), - StartupPhase::Starting | StartupPhase::Degraded => { - (iota_ipc::LifecyclePhase::Starting, Some("starting".into())) - } - }; - let _ = self.lifecycle.send(lifecycle); - let _ = self.startup_step.send(step); - } - - pub fn current_startup_phase(&self) -> StartupPhase { - *self.startup_phase.borrow() - } - - pub fn mark_degraded(&self, reason: String) { - let _ = self.degraded_reason.send(Some(reason.clone())); - self.set_component_degraded(iota_ipc::ComponentId::Omikron, reason); - } - - pub fn set_component_healthy(&self, component: iota_ipc::ComponentId, message: Option) { - self.update_component(component, iota_ipc::HealthStatus::Healthy, message); - } - - pub fn set_component_degraded(&self, component: iota_ipc::ComponentId, message: String) { - self.update_component(component, iota_ipc::HealthStatus::Degraded, Some(message)); - } - - pub fn set_component_failed(&self, component: iota_ipc::ComponentId, message: String) { - self.update_component(component, iota_ipc::HealthStatus::Failed, Some(message)); - } - - fn update_component( - &self, - component: iota_ipc::ComponentId, - status: iota_ipc::HealthStatus, - message: Option, - ) { - let mut components = self.components.borrow().clone(); - components.insert( - component, - iota_ipc::ComponentHealth { - status, - message, - changed_at_ms: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - }, - ); - let _ = self.components.send(components); - } - - pub fn overall_health(&self) -> iota_ipc::HealthStatus { - let components = self.components.borrow(); - if [iota_ipc::ComponentId::Ipc, iota_ipc::ComponentId::Storage] - .iter() - .any(|id| { - components - .get(id) - .is_some_and(|v| v.status == iota_ipc::HealthStatus::Failed) - }) - { - return iota_ipc::HealthStatus::Failed; - } - if components.values().any(|v| { - v.status == iota_ipc::HealthStatus::Degraded - || v.status == iota_ipc::HealthStatus::Failed - }) { - iota_ipc::HealthStatus::Degraded - } else { - iota_ipc::HealthStatus::Healthy - } - } - - pub fn snapshot(&self) -> StateSnapshot { - let state = self - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); - StateSnapshot { - cpu: state.cpu.clone(), - ram: state.ram.clone(), - ping: state.ping.clone(), - net_up: state.net_up.clone(), - net_down: state.net_down.clone(), - sys_info: state.sys_info.clone(), - startup_phase: self.current_startup_phase().into(), - degraded_reason: self.degraded_reason.borrow().clone(), - lifecycle: *self.lifecycle.borrow(), - startup_step: self.startup_step.borrow().clone(), - overall_health: self.overall_health(), - components: self.components.borrow().clone(), - } - } - - pub async fn spawn_system_monitor(&self) { - let runtime = self.clone(); - self.tasks - .spawn_tracked("system-monitor", async move { - runtime.state.active_tasks.insert("System monitor".into()); - let mut system = System::new_with_specifics(RefreshKind::everything()); - let mut counter = 0.0; - loop { - if runtime.is_shutting_down() { - break; - } - system.refresh_cpu_all(); - system.refresh_memory(); - let cpu = system.global_cpu_usage() as f64; - let total_memory = system.total_memory(); - let ram = if total_memory == 0 { - 0.0 - } else { - system.used_memory() as f64 / total_memory as f64 * 100.0 - }; - { - let mut state = runtime - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); - state.push_cpu((counter, cpu)); - state.push_ram((counter, ram)); - state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); - } - counter += 1.0; - tokio::time::sleep(Duration::from_millis(500)).await; - } - runtime.state.active_tasks.remove("System monitor"); - Ok(()) - }) - .await; - } -} diff --git a/iota-daemon-lib/src/deployment.rs b/iota-daemon-lib/src/deployment.rs deleted file mode 100644 index c7a5cde..0000000 --- a/iota-daemon-lib/src/deployment.rs +++ /dev/null @@ -1,37 +0,0 @@ -use iota_ipc::{DeploymentMode, SupervisorKind}; - -#[derive(Clone, Copy, Debug)] -pub struct DeploymentContext { - pub mode: DeploymentMode, - pub supervisor: SupervisorKind, -} - -impl Default for DeploymentContext { - fn default() -> Self { - Self { - mode: DeploymentMode::External, - supervisor: SupervisorKind::None, - } - } -} - -pub fn from_environment() -> DeploymentContext { - let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() { - Some("session_child") => DeploymentMode::SessionChild, - Some("ui_auto_start") => DeploymentMode::UiAutoStart, - Some("user_service") => DeploymentMode::UserService, - Some("system_socket_activated") => DeploymentMode::SystemSocketActivated, - Some("system_always_on") => DeploymentMode::SystemAlwaysOn, - _ => DeploymentMode::External, - }; - if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") { - mode = DeploymentMode::SystemSocketActivated; - } - let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() { - Some("iota_ui") => SupervisorKind::IotaUi, - Some("systemd") => SupervisorKind::Systemd, - Some("external") => SupervisorKind::External, - _ => SupervisorKind::None, - }; - DeploymentContext { mode, supervisor } -} diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs deleted file mode 100644 index a472348..0000000 --- a/iota-daemon-lib/src/ipc_server.rs +++ /dev/null @@ -1,745 +0,0 @@ -use crate::deployment::from_environment; -use crate::log_buffer::LogBuffer; -use crate::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext}; -use iota_ipc::{ - ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, - write_msg, -}; -use iota_logger::log; -use iota_storage::util::config_util; -use std::io::Result; -use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; -use tokio::io::AsyncWriteExt; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::{Semaphore, broadcast, mpsc, watch}; -use tokio::time::timeout; -use uuid::Uuid; - -/// Per-client outbound queue capacity. -const CLIENT_CHANNEL_SIZE: usize = 256; -const MAX_CONFIGURED_IPC_CLIENTS: usize = 4096; - -/// Maximum handshake retries before giving up. -const MAX_HANDSHAKE_RETRIES: u32 = 1; -const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); - -/// Minimum metric subscription interval to prevent excessive update rates. -const MIN_METRIC_INTERVAL_MS: u64 = 100; -/// Maximum metric subscription interval. -const MAX_METRIC_INTERVAL_MS: u64 = 60_000; -/// Default metric interval if the client does not specify one. -const DEFAULT_METRIC_INTERVAL_MS: u64 = 500; - -/// Per-client subscription state. -struct ClientSubscription { - log_classes: Vec, - metric_interval_ms: u64, -} - -enum WriterCommand { - Message(DaemonMessage), - Flush { - complete: tokio::sync::oneshot::Sender<()>, - }, -} - -fn configured_client_limit() -> usize { - config_util::CONFIG - .load() - .max_ipc_clients - .clamp(1, MAX_CONFIGURED_IPC_CLIENTS) -} - -pub struct IpcServer { - listener: UnixListener, - runtime: Arc, - services: Arc, - log_tx: broadcast::Sender, - log_buffer: Arc>, - state_rx: watch::Receiver, - instance_id: String, - _instance_lock: File, - client_limit: Arc, -} - -impl IpcServer { - pub async fn bind( - path: impl Into, - runtime: Arc, - services: Arc, - log_tx: broadcast::Sender, - log_buffer: Arc>, - state_rx: watch::Receiver, - ) -> Result { - let path = path.into(); - let listener = match activated_listener()? { - Some(listener) => listener, - None => { - let parent = path.parent().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "IPC socket has no parent directory", - ) - })?; - if !parent.is_dir() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("IPC runtime directory does not exist: {}", parent.display()), - )); - } - let lock_path = path - .parent() - .unwrap_or_else(|| Path::new("/tmp")) - .join("daemon.lock"); - let lock = File::options() - .create(true) - .mode(0o600) - .read(true) - .write(true) - .open(lock_path)?; - let locked = unsafe { - libc::flock( - std::os::fd::AsRawFd::as_raw_fd(&lock), - libc::LOCK_EX | libc::LOCK_NB, - ) - } == 0; - if !locked { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "another daemon instance is already running", - )); - } - remove_stale_socket(&path).await?; - let listener = UnixListener::bind(&path)?; - if let Err(error) = - tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)).await - { - drop(listener); - let _ = tokio::fs::remove_file(&path).await; - return Err(error); - } - if let Err(error) = validate_manual_socket(&path).await { - drop(listener); - let _ = tokio::fs::remove_file(&path).await; - return Err(error); - } - return Ok(Self { - listener, - runtime, - services, - log_tx, - log_buffer, - state_rx, - instance_id: Uuid::new_v4().to_string(), - _instance_lock: lock, - client_limit: Arc::new(Semaphore::new(configured_client_limit())), - }); - } - }; - Ok(Self { - listener, - runtime, - services, - log_tx, - log_buffer, - state_rx, - instance_id: Uuid::new_v4().to_string(), - _instance_lock: File::options().read(true).open("/dev/null")?, - client_limit: Arc::new(Semaphore::new(configured_client_limit())), - }) - } - - pub async fn serve(self) -> Result<()> { - loop { - let (stream, _addr) = self.listener.accept().await?; - let permit = match self.client_limit.clone().try_acquire_owned() { - Ok(permit) => permit, - Err(_) => { - eprintln!("IPC connection rejected: active client limit reached"); - drop(stream); - continue; - } - }; - eprintln!("IPC client accepted"); - let runtime = self.runtime.clone(); - let services = self.services.clone(); - let log_tx = self.log_tx.clone(); - let log_buffer = self.log_buffer.clone(); - let state_rx = self.state_rx.clone(); - let instance_id = self.instance_id.clone(); - tokio::spawn(async move { - let _permit = permit; - if let Err(error) = handle_client( - stream, - runtime, - services, - log_tx, - log_buffer, - state_rx, - instance_id, - ) - .await - { - eprintln!("IPC client error: {error}"); - } - }); - } - } -} - -/* systemd hands the first socket-activated file descriptor to the service as - * descriptor 3. Manual launches continue to bind the configured socket path. */ -fn activated_listener() -> Result> { - let listen_fds = env::var("LISTEN_FDS") - .ok() - .and_then(|value| value.parse::().ok()); - let listen_pid = env::var("LISTEN_PID") - .ok() - .and_then(|value| value.parse::().ok()); - if listen_fds != Some(1) || listen_pid != Some(std::process::id()) { - return Ok(None); - } - // SAFETY: systemd transfers ownership of the activated descriptor to us. - let listener = unsafe { StdUnixListener::from_raw_fd(3) }; - into_tokio_listener(listener).map(Some) -} - -fn into_tokio_listener(listener: StdUnixListener) -> Result { - listener.set_nonblocking(true)?; - UnixListener::from_std(listener) -} - -async fn write_client_message(writer: &mut W, message: &DaemonMessage) -> Result<()> -where - W: tokio::io::AsyncWrite + Unpin, -{ - timeout(CLIENT_IO_TIMEOUT, write_msg(writer, message)) - .await - .map_err(|_| { - std::io::Error::new(std::io::ErrorKind::TimedOut, "IPC client write timed out") - })? -} - -async fn remove_stale_socket(path: &Path) -> Result<()> { - match tokio::fs::symlink_metadata(path).await { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "IPC path exists but is not an owned Unix socket", - )); - } - if metadata.uid() != unsafe { libc::geteuid() } as u32 { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "existing IPC socket is not owned by the current user", - )); - } - match timeout( - std::time::Duration::from_millis(250), - UnixStream::connect(path), - ) - .await - { - Ok(Ok(_)) => Err(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "an IPC daemon is already listening", - )), - Ok(Err(error)) - if matches!( - error.kind(), - std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound - ) => - { - tokio::fs::remove_file(path).await - } - Ok(Err(error)) => Err(error), - Err(_) => Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "could not determine whether the existing IPC socket is active", - )), - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -async fn validate_manual_socket(path: &Path) -> Result<()> { - let metadata = tokio::fs::symlink_metadata(path).await?; - if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "bound IPC path is no longer a Unix socket", - )); - } - - let mode = metadata.permissions().mode() & 0o777; - if mode != 0o600 { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("IPC socket has unexpected mode {mode:o}"), - )); - } - - let expected_uid = unsafe { libc::geteuid() } as u32; - if metadata.uid() != expected_uid { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "IPC socket ownership changed after bind", - )); - } - Ok(()) -} - -#[derive(Clone, Debug)] -struct PeerIdentity { - pid: i32, - uid: u32, - _gid: u32, -} - -fn peer_credentials(stream: &UnixStream) -> Result { - #[cfg(target_os = "linux")] - { - use std::os::unix::io::AsRawFd; - unsafe { - let mut cred: libc::ucred = std::mem::zeroed(); - let mut len = std::mem::size_of::() as libc::socklen_t; - let fd = stream.as_raw_fd(); - if libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_PEERCRED, - &mut cred as *mut _ as *mut libc::c_void, - &mut len, - ) != 0 - { - return Err(std::io::Error::last_os_error()); - } - Ok(PeerIdentity { - pid: cred.pid, - uid: cred.uid, - _gid: cred.gid, - }) - } - } - #[cfg(not(target_os = "linux"))] - { - Ok(PeerIdentity { - pid: 0, - uid: 0, - _gid: 0, - }) - } -} - -fn role_for_peer(_peer: &PeerIdentity) -> IpcRole { - // This deployment has one IPC listener. Its Unix socket permissions are - // the admission boundary: systemd grants access to root, the daemon, and - // members of iota-operators. Once a peer has passed that boundary, it is - // an administrator for the operator console protocol. - IpcRole::Admin -} - -async fn handle_client( - stream: UnixStream, - runtime: Arc, - services: Arc, - log_tx: broadcast::Sender, - log_buffer: Arc>, - mut state_rx: watch::Receiver, - instance_id: String, -) -> Result<()> { - let peer_identity = peer_credentials(&stream)?; - let peer = PeerContext { - pid: peer_identity.pid, - uid: peer_identity.uid, - role: role_for_peer(&peer_identity), - }; - let (mut reader, mut writer) = stream.into_split(); - // A failed writer must stop the reader and any subsequent command work - // for this client; otherwise the reader can remain parked forever. - let session_cancellation = runtime.cancellation.child_token(); - let (directed_tx, directed_rx) = mpsc::channel::(CLIENT_CHANNEL_SIZE); - eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid); - - // --- Handshake --- - let mut negotiated_version: Option = None; - for _ in 0..MAX_HANDSHAKE_RETRIES { - match timeout( - std::time::Duration::from_secs(15), - read_msg::<_, ClientMessage>(&mut reader), - ) - .await - { - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "IPC Hello timed out", - )); - } - Ok(result) => match result { - Ok(ClientMessage::Hello { supported_versions }) => { - let version = supported_versions - .iter() - .copied() - .filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) - .max() - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::Unsupported, - "No compatible IPC protocol version", - ) - })?; - negotiated_version = Some(version); - let ack = DaemonMessage::HelloAck(HelloAck { - protocol_version: version, - daemon_version: env!("CARGO_PKG_VERSION").to_string(), - instance_id: instance_id.clone(), - startup_phase: runtime.current_startup_phase().into(), - capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], - lifecycle: *runtime.lifecycle.borrow(), - health: runtime.overall_health(), - deployment_mode: from_environment().mode, - supervisor: from_environment().supervisor, - }); - write_client_message(&mut writer, &ack).await?; - eprintln!( - "IPC handshake acknowledged (pid={}, uid={})", - peer.pid, peer.uid - ); - break; - } - Ok(_) => { - // Unexpected first message, send an error and close. - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Expected Hello as first message", - )); - } - Err(e) => return Err(e), - }, - } - } - let negotiated_version = negotiated_version.ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::Other, "Handshake failed after retries") - })?; - - log!("IPC client connected (pid={}, uid={})", peer.pid, peer.uid); - - // --- Send initial state snapshot --- - let initial = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(WriterCommand::Message(initial)).await; - - // --- Writer task: merge directed responses + shared log events --- - let mut log_rx = log_tx.subscribe(); - let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription { - log_classes: Vec::new(), - metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS, - }); - let writer_task = { - let runtime = runtime.clone(); - let session_cancellation = session_cancellation.clone(); - tokio::spawn(async move { - let mut directed_rx = directed_rx; - let mut last_metric_sent = tokio::time::Instant::now(); - let mut state_updates_open = true; - loop { - let metric_interval = sub_rx.borrow().metric_interval_ms; - tokio::select! { - _ = session_cancellation.cancelled() => break, - // Directed messages (responses to this client's requests) - command = directed_rx.recv() => { - match command { - Some(WriterCommand::Message(message)) => { - if let Err(error) = write_client_message(&mut writer, &message).await { - eprintln!("IPC client writer stopped while sending directed message: {error}"); - session_cancellation.cancel(); - break; - } - } - Some(WriterCommand::Flush { complete }) => { - if let Err(error) = writer.flush().await { - eprintln!("IPC client writer stopped while flushing: {error}"); - session_cancellation.cancel(); - break; - } - let _ = complete.send(()); - } - None => break, - } - } - // Shared log events - result = log_rx.recv() => { - match result { - Ok(DaemonMessage::LogEntry(entry)) => { - // Filter by subscribed log classes - let log_classes = sub_rx.borrow().log_classes.clone(); - if log_classes.is_empty() - || log_classes.iter().any(|c| entry.sender == *c) - { - if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await { - eprintln!("IPC client writer stopped while sending log message: {error}"); - session_cancellation.cancel(); - break; - } - } - } - Ok(DaemonMessage::MetricSample(sample)) => { - // Rate-limit metric samples based on subscription interval - let now = tokio::time::Instant::now(); - if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) { - last_metric_sent = now; - if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await { - eprintln!("IPC client writer stopped while sending metric sample: {error}"); - session_cancellation.cancel(); - break; - } - } - } - Ok(message) => { - // Forward other broadcast messages as-is - if let Err(error) = write_client_message(&mut writer, &message).await { - eprintln!("IPC client writer stopped while sending broadcast message: {error}"); - session_cancellation.cancel(); - break; - } - } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err() - || write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err() - { - session_cancellation.cancel(); - break; - } - } - Err(broadcast::error::RecvError::Closed) => { - session_cancellation.cancel(); - break; - } - } - } - changed = state_rx.changed(), if state_updates_open => { - if changed.is_err() { - state_updates_open = false; - continue; - } - let snapshot = state_rx.borrow().clone(); - if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await { - eprintln!("IPC client writer stopped while sending state update: {error}"); - session_cancellation.cancel(); - break; - } - } - _ = sub_rx.changed() => {} - } - } - }) - }; - - // --- Reader loop --- - let router = CommandRouter::new(runtime.clone(), services, log_buffer); - loop { - let message = tokio::select! { - _ = session_cancellation.cancelled() => break, - result = read_msg::<_, ClientMessage>(&mut reader) => result, - }; - match message { - Ok(ClientMessage::Request(envelope)) => { - let shutdown_reason = match &envelope.request { - iota_ipc::LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Restart, - } - | iota_ipc::LocalRequest::RestartDaemon => Some("restart requested"), - iota_ipc::LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - } => Some("shutdown requested"), - iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"), - _ => None, - }; - let response = if envelope.protocol_version != negotiated_version { - log!( - "IPC protocol mismatch: pid={}, uid={}, negotiated={}, request={}", - peer.pid, - peer.uid, - negotiated_version, - envelope.protocol_version - ); - iota_ipc::ResponseEnvelope { - request_id: envelope.request_id, - result: iota_ipc::ResponseResult::Error( - iota_ipc::IpcErrorCode::UnsupportedVersion, - ), - } - } else { - router - .route(&peer, envelope.request_id, envelope.request) - .await - }; - let should_shutdown = shutdown_reason.is_some() - && matches!(&response.result, iota_ipc::ResponseResult::Ok(_)); - let _ = directed_tx - .send(WriterCommand::Message(DaemonMessage::Response(response))) - .await; - if let Some(reason) = shutdown_reason.filter(|_| should_shutdown) { - let _ = directed_tx - .send(WriterCommand::Message(DaemonMessage::LifecycleEvent( - iota_ipc::LifecycleEvent::Shutdown { - reason: reason.into(), - }, - ))) - .await; - let (flush_tx, flush_rx) = tokio::sync::oneshot::channel(); - let _ = directed_tx - .send(WriterCommand::Flush { complete: flush_tx }) - .await; - timeout(CLIENT_IO_TIMEOUT, flush_rx) - .await - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "IPC shutdown response flush timed out", - ) - })? - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "IPC writer stopped before shutdown flush", - ) - })?; - runtime.begin_shutdown(); - break; - } - } - Ok(ClientMessage::Subscribe { - log_classes, - metric_interval_ms, - }) => { - let interval = metric_interval_ms - .unwrap_or(DEFAULT_METRIC_INTERVAL_MS) - .clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS); - let _ = sub_tx.send(ClientSubscription { - log_classes, - metric_interval_ms: interval, - }); - let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(WriterCommand::Message(snapshot)).await; - let _ = directed_tx - .send(WriterCommand::Message(DaemonMessage::Subscribed)) - .await; - } - Ok(ClientMessage::Ping { seq }) => { - let _ = directed_tx - .send(WriterCommand::Message(DaemonMessage::Pong { seq })) - .await; - } - Ok(ClientMessage::Hello { .. }) => { - // Re-handshake on existing connection: treat as resubscribe - let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); - let _ = directed_tx.send(WriterCommand::Message(snapshot)).await; - } - Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break, - Err(error) => { - session_cancellation.cancel(); - drop(directed_tx); - let mut writer_task = writer_task; - match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await { - Ok(_) => {} - Err(_) => { - writer_task.abort(); - let _ = writer_task.await; - } - } - return Err(error); - } - } - } - drop(directed_tx); - session_cancellation.cancel(); - let mut writer_task = writer_task; - match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await { - Ok(Ok(())) => {} - Ok(Err(error)) => { - eprintln!("IPC client writer task failed: {error}"); - } - Err(_) => { - eprintln!("IPC client writer did not stop before timeout"); - writer_task.abort(); - let _ = writer_task.await; - } - } - log!( - "IPC client disconnected (pid={}, uid={})", - peer.pid, - peer.uid - ); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[tokio::test(flavor = "current_thread")] - async fn converted_listener_does_not_block_the_runtime() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("ipc.sock"); - let listener = match StdUnixListener::bind(path) { - Ok(listener) => into_tokio_listener(listener).unwrap(), - Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return, - Err(error) => panic!("could not create test socket: {error}"), - }; - assert!( - tokio::time::timeout(Duration::from_millis(50), listener.accept()) - .await - .is_err() - ); - } - - #[tokio::test] - async fn manual_socket_validation_requires_owner_only_mode() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("ipc.sock"); - let listener = StdUnixListener::bind(&path).expect("test socket binds"); - tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)) - .await - .expect("test socket permissions apply"); - - validate_manual_socket(&path) - .await - .expect("manual socket validation succeeds"); - drop(listener); - } - - #[tokio::test] - async fn manual_socket_validation_rejects_unexpected_mode() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("ipc.sock"); - let listener = StdUnixListener::bind(&path).expect("test socket binds"); - tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o660)) - .await - .expect("test socket permissions apply"); - - let error = validate_manual_socket(&path) - .await - .expect_err("group-accessible manual socket must be rejected"); - assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); - drop(listener); - } - - #[test] - fn an_admitted_operator_peer_receives_administrator_role() { - let peer = PeerIdentity { - pid: 123, - uid: 1000, - _gid: 1000, - }; - - assert_eq!(role_for_peer(&peer), IpcRole::Admin); - } -} diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs deleted file mode 100644 index 3afeff8..0000000 --- a/iota-daemon-lib/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod command_router; -pub mod daemon_state; -pub mod deployment; -pub mod ipc_server; -pub mod log_broadcaster; -pub mod log_buffer; -pub mod services; -pub mod task_registry; - -pub use command_router::{CommandRouter, IpcRole, PeerContext}; -pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase}; -pub use ipc_server::IpcServer; -pub use services::DaemonServices; -pub use task_registry::TaskRegistry; diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs deleted file mode 100644 index 9bb1d05..0000000 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::log_buffer::LogBuffer; -use iota_ipc::{DaemonMessage, LogEntry}; -use iota_logger::subscribe; -use std::sync::{Arc, Mutex}; -use tokio::sync::broadcast; - -/* The daemon adapts logger output to the wire protocol so the logger stays - * independent from both the socket implementation and TUI state. */ -pub fn spawn(message_tx: broadcast::Sender, buffer: Arc>) { - let Some(mut logs) = subscribe() else { - return; - }; - tokio::spawn(async move { - while let Ok(entry) = logs.recv().await { - let entry = LogEntry { - timestamp_ms: entry.timestamp_ms, - sender: entry.sender, - message: entry.message, - is_error: entry.is_error, - }; - if let Ok(mut buf) = buffer.lock() { - buf.push(entry.clone()); - } - let _ = message_tx.send(DaemonMessage::LogEntry(entry)); - } - }); -} diff --git a/iota-daemon-lib/src/log_buffer.rs b/iota-daemon-lib/src/log_buffer.rs deleted file mode 100644 index bb2cdc6..0000000 --- a/iota-daemon-lib/src/log_buffer.rs +++ /dev/null @@ -1,36 +0,0 @@ -use iota_ipc::LogEntry; -use std::collections::VecDeque; - -pub struct LogBuffer { - entries: VecDeque, - capacity: usize, -} - -impl LogBuffer { - pub fn new(capacity: usize) -> Self { - Self { - entries: VecDeque::with_capacity(capacity), - capacity, - } - } - - pub fn push(&mut self, entry: LogEntry) { - if self.entries.len() == self.capacity { - self.entries.pop_front(); - } - self.entries.push_back(entry); - } - - pub fn recent(&self, limit: usize) -> Vec { - let _len = self.entries.len(); - self.entries - .iter() - .rev() - .take(limit) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect() - } -} diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs deleted file mode 100644 index b63b574..0000000 --- a/iota-daemon-lib/src/services.rs +++ /dev/null @@ -1,72 +0,0 @@ -use async_trait::async_trait; -use mtp::codec::CommunicationValue; -use omikron_connector::{OmikronClient, OmikronConnection, OmikronError}; -use std::sync::Arc; -use std::time::Duration; - -#[derive(Default)] -pub struct UserService; -#[derive(Default)] -pub struct ConfigService; - -pub struct DaemonServices { - pub omikron: Arc, - pub users: Arc, - pub config: Arc, - pub active: bool, -} - -impl DaemonServices { - pub fn new(omikron: Arc) -> Arc { - Arc::new(Self { - omikron, - users: Arc::new(UserService), - config: Arc::new(ConfigService), - active: true, - }) - } - - /// Services used while the daemon is awaiting terms acceptance. They can - /// never initiate a connection; the command router exposes status only. - pub fn inactive() -> Arc { - Arc::new(Self { - omikron: Arc::new(InactiveOmikron), - users: Arc::new(UserService), - config: Arc::new(ConfigService), - active: false, - }) - } -} - -struct InactiveOmikron; - -#[async_trait] -impl OmikronClient for InactiveOmikron { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn await_response( - &self, - _: &CommunicationValue, - _: Duration, - ) -> Result { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn reconnect(&self) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn rotate_identity(&self) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn is_connected(&self) -> bool { - false - } -} diff --git a/iota-daemon-lib/src/task_registry.rs b/iota-daemon-lib/src/task_registry.rs deleted file mode 100644 index 89769db..0000000 --- a/iota-daemon-lib/src/task_registry.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Mutex; -use tokio::task::JoinSet; - -#[derive(Clone, Default)] -pub struct TaskRegistry { - tasks: Arc)>>>, -} - -impl TaskRegistry { - pub async fn spawn_tracked(&self, name: impl Into, future: F) - where - F: std::future::Future> + Send + 'static, - { - let name = name.into(); - self.tasks - .lock() - .await - .spawn(async move { (name, future.await) }); - } - - pub async fn join_with_timeout(&self, timeout: Duration) -> Vec { - let mut tasks = self.tasks.lock().await; - let mut failures = Vec::new(); - let deadline = tokio::time::Instant::now() + timeout; - while !tasks.is_empty() { - match tokio::time::timeout_at(deadline, tasks.join_next()).await { - Ok(Some(Ok((name, Err(error))))) => failures.push(format!("{name}: {error}")), - Ok(Some(Ok((_, Ok(()))))) | Ok(Some(Err(_))) => {} - Ok(None) => break, - Err(_) => { - tasks.abort_all(); - break; - } - } - } - failures - } -} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs deleted file mode 100644 index 99a2aaa..0000000 --- a/iota-daemon-lib/tests/command_router.rs +++ /dev/null @@ -1,141 +0,0 @@ -use async_trait::async_trait; -use iota_daemon_lib::log_buffer::LogBuffer; -use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext}; -use iota_ipc::{IpcErrorCode, LocalRequest, ResponseResult}; -use mtp::codec::CommunicationValue; -use omikron_connector::{OmikronClient, OmikronError}; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, -}; -use std::time::Duration; - -struct FakeOmikron { - reconnects: AtomicUsize, -} - -fn admin_peer() -> PeerContext { - PeerContext { - pid: 1, - uid: 0, - role: IpcRole::Admin, - } -} - -fn read_peer() -> PeerContext { - PeerContext { - pid: 2, - uid: 1000, - role: IpcRole::Read, - } -} -#[async_trait] -impl OmikronClient for FakeOmikron { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - Ok(()) - } - async fn await_response( - &self, - _: &CommunicationValue, - _: Duration, - ) -> Result { - Err(OmikronError::Disconnected("fake".into())) - } - async fn reconnect(&self) -> Result<(), OmikronError> { - self.reconnects.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - async fn rotate_identity(&self) -> Result<(), OmikronError> { - self.reconnects.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - async fn is_connected(&self) -> bool { - false - } -} - -#[tokio::test] -async fn reconnect_uses_the_injected_client() { - let fake = Arc::new(FakeOmikron { - reconnects: AtomicUsize::new(0), - }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); - let router = CommandRouter::new( - Arc::new(DaemonRuntime::new()), - services, - Arc::new(Mutex::new(LogBuffer::new(100))), - ); - assert!(matches!( - router - .route(&admin_peer(), 1, LocalRequest::ReconnectOmikron) - .await - .result, - ResponseResult::Ok(_) - )); - assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn identity_rotation_is_available_while_omikron_is_offline() { - let fake = Arc::new(FakeOmikron { - reconnects: AtomicUsize::new(0), - }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); - let router = CommandRouter::new( - Arc::new(DaemonRuntime::new()), - services, - Arc::new(Mutex::new(LogBuffer::new(100))), - ); - assert!(matches!( - router - .route(&admin_peer(), 1, LocalRequest::RotateIotaIdentity) - .await - .result, - ResponseResult::Ok(_) - )); - assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn read_role_cannot_execute_an_administrative_request() { - let fake = Arc::new(FakeOmikron { - reconnects: AtomicUsize::new(0), - }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); - let router = CommandRouter::new( - Arc::new(DaemonRuntime::new()), - services, - Arc::new(Mutex::new(LogBuffer::new(100))), - ); - - assert!(matches!( - router - .route( - &read_peer(), - 9, - LocalRequest::SetConfig { - key: "port".into(), - value: "1984".into(), - }, - ) - .await - .result, - ResponseResult::Error(IpcErrorCode::Unauthorized) - )); - assert_eq!(fake.reconnects.load(Ordering::SeqCst), 0); -} diff --git a/iota-daemon-lib/tests/daemon_health.rs b/iota-daemon-lib/tests/daemon_health.rs deleted file mode 100644 index a24f277..0000000 --- a/iota-daemon-lib/tests/daemon_health.rs +++ /dev/null @@ -1,29 +0,0 @@ -use iota_daemon_lib::{DaemonRuntime, StartupPhase}; -use iota_ipc::{ComponentId, HealthStatus, LifecyclePhase}; - -#[test] -fn component_failures_are_independent_and_recovery_is_scoped() { - let runtime = DaemonRuntime::new(); - runtime.set_component_degraded(ComponentId::Omikron, "offline".into()); - runtime.set_component_failed(ComponentId::Web, "bind failed".into()); - runtime.set_startup_phase(StartupPhase::Ready); - let snapshot = runtime.snapshot(); - assert_eq!(snapshot.lifecycle, LifecyclePhase::Ready); - assert_eq!(snapshot.overall_health, HealthStatus::Degraded); - assert_eq!( - snapshot.components[&ComponentId::Omikron].status, - HealthStatus::Degraded - ); - runtime.set_component_healthy(ComponentId::Web, None); - assert_eq!( - runtime.snapshot().components[&ComponentId::Omikron].status, - HealthStatus::Degraded - ); -} - -#[test] -fn critical_failure_is_failed_but_optional_degradation_is_not() { - let runtime = DaemonRuntime::new(); - runtime.set_component_failed(ComponentId::Storage, "database unavailable".into()); - assert_eq!(runtime.snapshot().overall_health, HealthStatus::Failed); -} diff --git a/iota-daemon-lib/tests/ipc_server.rs b/iota-daemon-lib/tests/ipc_server.rs deleted file mode 100644 index 7540ea4..0000000 --- a/iota-daemon-lib/tests/ipc_server.rs +++ /dev/null @@ -1,252 +0,0 @@ -use async_trait::async_trait; -use iota_daemon_lib::{DaemonRuntime, DaemonServices, IpcServer}; -use iota_ipc::{ - ClientMessage, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, PROTOCOL_VERSION, - RequestEnvelope, ResponseResult, read_msg, write_msg, -}; -use iota_storage::util::config_util::{self, IotaConfig}; -use mtp::codec::CommunicationValue; -use omikron_connector::{OmikronClient, OmikronError}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; -use tokio::net::UnixStream; -use tokio::sync::{broadcast, watch}; - -struct ConfigRestore(Arc); - -impl Drop for ConfigRestore { - fn drop(&mut self) { - config_util::CONFIG.store(self.0.clone()); - } -} - -fn set_client_limit(limit: usize) -> ConfigRestore { - let previous = config_util::CONFIG.load_full(); - let mut config = (*previous).clone(); - config.max_ipc_clients = limit; - config_util::CONFIG.store(Arc::new(config)); - ConfigRestore(previous) -} - -async fn start_server( - path: &Path, - services: Arc, -) -> (Arc, tokio::task::JoinHandle<()>) { - let runtime = Arc::new(DaemonRuntime::new()); - let (log_tx, _) = broadcast::channel(32); - let log_buffer = Arc::new(std::sync::Mutex::new( - iota_daemon_lib::log_buffer::LogBuffer::new(32), - )); - let (_, state_rx) = watch::channel(runtime.snapshot()); - let server = IpcServer::bind( - path.to_owned(), - runtime.clone(), - services, - log_tx, - log_buffer, - state_rx, - ) - .await - .expect("IPC server binds"); - let task = tokio::spawn(async move { - let _ = server.serve().await; - }); - (runtime, task) -} - -async fn try_connect_and_await_hello(path: &Path) -> std::io::Result { - let mut stream = UnixStream::connect(path).await?; - write_msg( - &mut stream, - &ClientMessage::Hello { - supported_versions: vec![PROTOCOL_VERSION], - }, - ) - .await?; - let message: DaemonMessage = read_msg(&mut stream).await?; - if !matches!(message, DaemonMessage::HelloAck(_)) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "expected HelloAck", - )); - } - Ok(stream) -} - -async fn connect_and_await_hello(path: &Path) -> UnixStream { - try_connect_and_await_hello(path) - .await - .expect("IPC connection completes the Hello exchange") -} - -#[tokio::test] -async fn active_client_limit_rejects_excess_clients_and_releases_permits() { - let _config = set_client_limit(1); - let directory = tempfile::tempdir().unwrap(); - let socket = directory.path().join("ipc.sock"); - let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await; - - let first = connect_and_await_hello(&socket).await; - let mut rejected = UnixStream::connect(&socket) - .await - .expect("second connection reaches the Unix listener"); - let rejected_result = tokio::time::timeout( - Duration::from_secs(2), - read_msg::<_, DaemonMessage>(&mut rejected), - ) - .await - .expect("rejected client is closed promptly"); - assert!(rejected_result.is_err()); - - drop(first); - let deadline = tokio::time::Instant::now() + Duration::from_secs(2); - let _released = loop { - match try_connect_and_await_hello(&socket).await { - Ok(stream) => break stream, - Err(_error) if tokio::time::Instant::now() < deadline => { - tokio::time::sleep(Duration::from_millis(10)).await; - } - Err(error) => panic!("client permit was not released: {error}"), - } - }; - server_task.abort(); - let _ = server_task.await; -} - -#[tokio::test] -async fn request_with_version_different_from_hello_is_rejected() { - let directory = tempfile::tempdir().unwrap(); - let socket = directory.path().join("ipc.sock"); - let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await; - let mut stream = connect_and_await_hello(&socket).await; - - write_msg( - &mut stream, - &ClientMessage::Request(RequestEnvelope { - request_id: 7, - protocol_version: PROTOCOL_VERSION + 1, - request: LocalRequest::GetStatus, - }), - ) - .await - .expect("request sends"); - - let response = loop { - match read_msg::<_, DaemonMessage>(&mut stream) - .await - .expect("daemon response arrives") - { - DaemonMessage::Response(response) => break response, - _ => continue, - } - }; - assert_eq!(response.request_id, 7); - assert!(matches!( - response.result, - ResponseResult::Error(IpcErrorCode::UnsupportedVersion) - )); - - drop(stream); - server_task.abort(); - let _ = server_task.await; -} - -struct TestOmikron; - -#[async_trait] -impl OmikronClient for TestOmikron { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - Ok(()) - } - - async fn await_response( - &self, - _: &CommunicationValue, - _: Duration, - ) -> Result { - Err(OmikronError::Disconnected("test client".into())) - } - - async fn reconnect(&self) -> Result<(), OmikronError> { - Ok(()) - } - - async fn rotate_identity(&self) -> Result<(), OmikronError> { - Ok(()) - } - - async fn is_connected(&self) -> bool { - true - } -} - -fn active_services() -> Arc { - Arc::new(DaemonServices { - omikron: Arc::new(TestOmikron), - users: Default::default(), - config: Default::default(), - active: true, - }) -} - -#[tokio::test] -async fn shutdown_delivers_response_and_lifecycle_event_before_eof() { - let directory = tempfile::tempdir().unwrap(); - let socket = directory.path().join("ipc.sock"); - let (runtime, server_task) = start_server(&socket, active_services()).await; - let mut stream = connect_and_await_hello(&socket).await; - - write_msg( - &mut stream, - &ClientMessage::Request(RequestEnvelope { - request_id: 8, - protocol_version: PROTOCOL_VERSION, - request: LocalRequest::RequestProcessExit { - intent: ExitIntent::Stop, - }, - }), - ) - .await - .expect("shutdown request sends"); - - let mut response_seen = false; - let mut lifecycle_seen = false; - for _ in 0..4 { - match tokio::time::timeout( - Duration::from_secs(2), - read_msg::<_, DaemonMessage>(&mut stream), - ) - .await - .expect("shutdown message arrives") - .expect("shutdown stream remains readable") - { - DaemonMessage::Response(response) => { - assert_eq!(response.request_id, 8); - assert!(matches!(response.result, ResponseResult::Ok(_))); - response_seen = true; - } - DaemonMessage::LifecycleEvent(iota_ipc::LifecycleEvent::Shutdown { .. }) => { - lifecycle_seen = true; - } - _ => {} - } - if response_seen && lifecycle_seen { - break; - } - } - - assert!(response_seen); - assert!(lifecycle_seen); - let eof = tokio::time::timeout( - Duration::from_secs(2), - read_msg::<_, DaemonMessage>(&mut stream), - ) - .await - .expect("shutdown connection closes after flush"); - assert!(eof.is_err()); - assert!(runtime.is_shutting_down()); - - server_task.abort(); - let _ = server_task.await; -} diff --git a/iota-daemon-lib/tests/shutdown.rs b/iota-daemon-lib/tests/shutdown.rs deleted file mode 100644 index d0c74d6..0000000 --- a/iota-daemon-lib/tests/shutdown.rs +++ /dev/null @@ -1,40 +0,0 @@ -use iota_daemon_lib::{DaemonRuntime, ShutdownReason}; -use std::time::Duration; - -#[tokio::test] -async fn shutdown_reason_is_first_write_wins_and_tasks_join() { - let runtime = DaemonRuntime::new(); - runtime.shutdown(ShutdownReason::Fatal("first".into())); - runtime.shutdown(ShutdownReason::Restart); - assert_eq!( - runtime.shutdown_reason(), - Some(ShutdownReason::Fatal("first".into())) - ); - runtime.tasks.spawn_tracked("quick", async { Ok(()) }).await; - assert!( - runtime - .tasks - .join_with_timeout(Duration::from_millis(100)) - .await - .is_empty() - ); -} - -#[tokio::test] -async fn long_task_is_aborted_at_join_timeout() { - let runtime = DaemonRuntime::new(); - runtime - .tasks - .spawn_tracked("slow", async { - tokio::time::sleep(Duration::from_secs(10)).await; - Ok(()) - }) - .await; - assert!( - runtime - .tasks - .join_with_timeout(Duration::from_millis(10)) - .await - .is_empty() - ); -} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml deleted file mode 100644 index 87a5636..0000000 --- a/iota-daemon/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "iota-daemon" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-daemon-lib = { path = "../iota-daemon-lib" } -iota-ipc = { path = "../iota-ipc" } -iota-logger = { path = "../iota-logger" } -iota-paths = { path = "../iota-paths" } -iota-storage = { path = "../iota-storage" } -iota-util = { path = "../iota-util" } -iota-terms = { path = "../iota-terms" } -omikron-connector = { path = "../omikron-connector" } -web-server = { path = "../web-server" } -tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs deleted file mode 100644 index 4ebe682..0000000 --- a/iota-daemon/src/main.rs +++ /dev/null @@ -1,473 +0,0 @@ -use iota_daemon_lib::{ - DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster, - log_buffer::LogBuffer, -}; -use iota_logger::{self as logger, log}; -use iota_storage::users::user_manager; -use iota_storage::util::config_util::CONFIG; -use std::process::ExitCode; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tokio::sync::{broadcast, watch}; - -const MESSAGE_RETENTION_INTERVAL: Duration = Duration::from_secs(60); -const SYNC_COMPACTION_INTERVAL: Duration = Duration::from_secs(60 * 60); -#[tokio::main(flavor = "multi_thread")] -async fn main() -> ExitCode { - let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() { - Some("system_socket_activated") | Some("system_always_on") => iota_paths::Scope::System, - _ => iota_paths::Scope::User, - }; - let paths = match iota_paths::IotaPaths::resolve(scope) { - Ok(paths) => paths, - Err(error) => { - eprintln!("Cannot resolve Iota paths: {error}"); - return ExitCode::FAILURE; - } - }; - // Bind a deliberately dormant IPC daemon before terms are accepted. This - // makes socket activation and `iota terms accept --system` usable, while - // the router exposes status only and the inactive service cannot connect. - if !iota_terms::consent::load(&paths.state_dir).has_all_required() { - let socket = match &paths.ipc_endpoint { - iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), - iota_paths::IpcEndpoint::WindowsPipe(_) => { - eprintln!("Windows named-pipe daemon transport is not implemented yet"); - return ExitCode::FAILURE; - } - }; - let runtime = Arc::new(DaemonRuntime::new()); - let (log_tx, _) = broadcast::channel(64); - let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64))); - let (_, state_rx) = watch::channel(runtime.snapshot()); - let server = match IpcServer::bind( - socket, - runtime.clone(), - DaemonServices::inactive(), - log_tx, - log_buffer, - state_rx, - ) - .await - { - Ok(server) => server, - Err(error) => { - eprintln!("Cannot bind dormant daemon IPC socket: {error}"); - return ExitCode::FAILURE; - } - }; - tokio::spawn(async move { - let _ = server.serve().await; - }); - eprintln!( - "Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.", - if paths.scope == iota_paths::Scope::System { - " --system" - } else { - "" - } - ); - loop { - tokio::select! { - _ = tokio::time::sleep(Duration::from_secs(1)) => { - if iota_terms::consent::load(&paths.state_dir).has_all_required() { - // systemd restarts this daemon; a locally-launched daemon can - // simply be started again after accepting the documents. - return ExitCode::from(75); - } - } - _ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS, - } - } - } - if let Err(error) = paths.migrate_legacy_layout() { - eprintln!("Cannot migrate legacy Iota layout: {error}"); - return ExitCode::FAILURE; - } - if let Err(error) = paths.prepare_writable_directories() { - eprintln!("Cannot prepare Iota directories: {error}"); - return ExitCode::FAILURE; - } - iota_util::file_util::configure_storage_directory(paths.storage_dir.clone()); - iota_storage::util::config_util::configure_config_path(paths.config_file.clone()); - iota_storage::util::config_util::load_config_from(&paths.config_file); - omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file()); - match paths.scope { - iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())), - iota_paths::Scope::System => logger::startup_with_log_dir(None), - } - - let runtime = Arc::new(DaemonRuntime::new()); - // --- IPC infrastructure --- - let (log_tx, _) = broadcast::channel(512); - let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024))); - log_broadcaster::spawn(log_tx.clone(), log_buffer.clone()); - let (state_tx, state_rx) = watch::channel(runtime.snapshot()); - - runtime.set_startup_phase(StartupPhase::LoadingUsers); - let storage_error = match iota_storage::util::db::verify_and_backup_database() { - Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync) - .await - .map_err(|error| format!("user storage task failed: {error}")) - .and_then(|result| result.map_err(|error| error.to_string())) - .err(), - Err(error) => Some(error.to_string()), - }; - if let Some(error) = storage_error { - runtime.set_component_failed( - iota_ipc::ComponentId::Storage, - format!("user storage failed to load: {error}"), - ); - } else { - runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None); - } - - // Bind before migration and service startup: a successful bind is the - // readiness boundary visible to clients and socket activation. - let socket = match &paths.ipc_endpoint { - iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), - iota_paths::IpcEndpoint::WindowsPipe(_) => { - eprintln!("Windows named-pipe daemon transport is not implemented yet"); - return ExitCode::FAILURE; - } - }; - let omikron = match omikron_connector::omikron_connection::connect_initial( - runtime.cancellation.clone(), - runtime.state.active_tasks.clone(), - runtime.state.app.clone(), - ) - .await - { - Ok(connection) => connection, - Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => { - runtime.set_component_degraded( - iota_ipc::ComponentId::Omikron, - "Omikron connection unavailable; retrying".into(), - ); - connection - } - Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { - runtime.set_component_failed( - iota_ipc::ComponentId::Omikron, - "Omikron authentication failed; regenerate the Iota identity to register again" - .into(), - ); - // Keep IPC alive: identity rotation is the supported recovery - // action and must remain available after authentication fails. - connection - } - Err(omikron_connector::OmikronStartupError::Construction(error)) => { - eprintln!("Cannot construct Omikron connection: {error}"); - return ExitCode::FAILURE; - } - }; - let omikron_health = omikron.clone(); - let omikron_reconcile = omikron.clone(); - let services = DaemonServices::new(omikron); - let health_runtime = runtime.clone(); - runtime - .tasks - .spawn_tracked("omikron-health", async move { - let mut states = omikron_health.connection_state(); - loop { - let state = *states.borrow(); - match state { - omikron_connector::omikron_connection::ConnectionState::Connected { - .. - } => { - let ping_ms = *omikron_health.last_ping.lock().await; - let message = if ping_ms >= 0 { - format!("connected (RTT: {ping_ms} ms)") - } else { - "connected (waiting for RTT sample)".into() - }; - health_runtime - .set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message)); - } - omikron_connector::omikron_connection::ConnectionState::Connecting => { - health_runtime.set_component_degraded( - iota_ipc::ComponentId::Omikron, - "connecting to Omikron".into(), - ); - } - omikron_connector::omikron_connection::ConnectionState::Disconnected => { - let message = omikron_health - .get_auth_failure() - .await - .unwrap_or_else(|| "disconnected; retrying".into()); - if omikron_health.has_auth_failure().await { - health_runtime - .set_component_failed(iota_ipc::ComponentId::Omikron, message); - } else { - health_runtime - .set_component_degraded(iota_ipc::ComponentId::Omikron, message); - } - } - } - tokio::select! { - changed = states.changed() => if changed.is_err() { break }, - // RTT is updated by MTP's heartbeat independently of a - // connection-state transition, so periodically refresh - // the component detail while connected. - _ = tokio::time::sleep(Duration::from_secs(1)) => {}, - _ = health_runtime.cancellation.cancelled() => break, - } - } - Ok(()) - }) - .await; - runtime - .tasks - .spawn_tracked("user-lifecycle-reconciliation", async move { - let mut states = omikron_reconcile.connection_state(); - loop { - if matches!( - *states.borrow(), - omikron_connector::omikron_connection::ConnectionState::Connected { .. } - ) { - omikron_connector::user_ops::reconcile_managed_users( - omikron_reconcile.as_ref(), - ) - .await; - } - tokio::select! { - changed = states.changed() => if changed.is_err() { break }, - _ = tokio::time::sleep(Duration::from_secs(30)) => {}, - } - } - Ok(()) - }) - .await; - let ipc_server = match IpcServer::bind( - socket.clone(), - runtime.clone(), - services, - log_tx.clone(), - log_buffer.clone(), - state_rx, - ) - .await - { - Ok(server) => server, - Err(error) => { - eprintln!("Cannot bind daemon IPC socket: {error}"); - return ExitCode::FAILURE; - } - }; - eprintln!("iota-daemon IPC listener ready at {}", socket.display()); - runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None); - let listener_runtime = runtime.clone(); - runtime - .tasks - .spawn_tracked("ipc-server", async move { - if let Err(error) = ipc_server.serve().await { - eprintln!("iota-daemon IPC server failed: {error}"); - listener_runtime.shutdown(ShutdownReason::Fatal(format!( - "IPC listener stopped: {error}" - ))); - } - Ok(()) - }) - .await; - log!("iota-daemon IPC server ready"); - - log!( - "iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}", - paths.scope, - paths.config_file.display(), - paths.state_dir.display(), - paths.storage_dir.display(), - paths.identity_dir.display(), - paths.cache_dir.display(), - paths.log_dir.display(), - paths.asset_dir.display(), - match &paths.ipc_endpoint { - iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(), - iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(), - }, - ); - runtime.set_startup_phase(StartupPhase::StartingServices); - - // --- System monitor --- - runtime.spawn_system_monitor().await; - - // --- State update publisher (watch-based, no full broadcast per tick) --- - let state_publisher = runtime.clone(); - runtime - .tasks - .spawn_tracked("state-publisher", async move { - loop { - if state_publisher.is_shutting_down() { - break; - } - let snapshot = state_publisher.snapshot(); - let _ = state_tx.send(snapshot); - tokio::time::sleep(Duration::from_millis(500)).await; - } - Ok(()) - }) - .await; - - // --- Web server --- - let web = CONFIG.load().web.clone(); - let web_config = web_server::WebConfig { - mode: match web.mode { - iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled, - iota_storage::util::config_util::WebMode::Loopback => web_server::WebMode::Loopback, - iota_storage::util::config_util::WebMode::Network => web_server::WebMode::Network, - }, - bind: web - .bind - .parse() - .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), - port: web.port, - asset_dir: resolve_config_path(&paths.config_file, &web.asset_dir, &paths.asset_dir), - tls: web - .certificate - .zip(web.key) - .map(|(certificate, key)| web_server::TlsConfig { - certificate: resolve_config_path( - &paths.config_file, - &certificate, - &paths.config_dir, - ), - key: resolve_config_path(&paths.config_file, &key, &paths.config_dir), - }), - required: web.required, - }; - match web_server::start(web_config, runtime.cancellation.clone()).await { - Ok(None) => { - runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into())) - } - Ok(Some(handle)) => { - runtime.set_component_healthy(iota_ipc::ComponentId::Web, None); - runtime - .tasks - .spawn_tracked("web-server", async move { - handle.join().await; - Ok(()) - }) - .await; - } - Err(error) if web.required => { - runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string()); - } - Err(error) => { - runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string()); - } - } - - runtime.set_startup_phase(StartupPhase::Ready); - log!("iota-daemon started (phase: Ready)"); - - let retention_runtime = runtime.clone(); - runtime - .tasks - .spawn_tracked("message-retention", async move { - loop { - let purge = tokio::task::spawn_blocking(|| { - iota_storage::util::message_retention::purge_expired_messages( - iota_storage::util::sync::now_millis(), - ) - }) - .await; - match purge { - Ok(Ok(result)) if result.deleted_messages > 0 => { - log!("purged {} expired messages", result.deleted_messages); - } - Ok(Ok(_)) => {} - Ok(Err(error)) => log!("message retention cleanup failed: {}", error), - Err(error) => log!("message retention task failed: {}", error), - } - tokio::select! { - _ = tokio::time::sleep(MESSAGE_RETENTION_INTERVAL) => {}, - _ = retention_runtime.cancellation.cancelled() => break, - } - } - Ok(()) - }) - .await; - - let compaction_runtime = runtime.clone(); - runtime - .tasks - .spawn_tracked("sync-compaction", async move { - loop { - let compact = - tokio::task::spawn_blocking(iota_storage::util::sync::compact_all_sync_state) - .await; - match compact { - Ok(Ok(result)) - if result.removed_events > 0 || result.removed_blob_tombstones > 0 => - { - log!( - "compacted {} sync events and {} blob tombstones", - result.removed_events, - result.removed_blob_tombstones - ); - } - Ok(Ok(_)) => {} - Ok(Err(error)) => log!("sync compaction failed: {}", error), - Err(error) => log!("sync compaction task failed: {}", error), - } - tokio::select! { - _ = tokio::time::sleep(SYNC_COMPACTION_INTERVAL) => {}, - _ = compaction_runtime.cancellation.cancelled() => break, - } - } - Ok(()) - }) - .await; - - // --- Main lifecycle loop --- - let signal = async { - #[cfg(unix)] - { - let mut term = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("SIGTERM handler"); - tokio::select! { _ = tokio::signal::ctrl_c() => ShutdownReason::Stop, _ = term.recv() => ShutdownReason::Stop } - } - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; - ShutdownReason::Stop - } - }; - tokio::select! { - _ = runtime.cancellation.cancelled() => {}, - reason = signal => runtime.shutdown(reason), - } - - let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop); - log!("iota-daemon shutting down (reason: {:?})", reason); - runtime.set_startup_phase(StartupPhase::Stopping); - - let _ = runtime - .tasks - .join_with_timeout(Duration::from_secs(5)) - .await; - - let exit_code = reason.exit_code(); - log!("iota-daemon exited (code: {})", exit_code); - ExitCode::from(exit_code as u8) -} - -fn resolve_config_path( - config_file: &std::path::Path, - value: &str, - default: &std::path::Path, -) -> std::path::PathBuf { - if value.is_empty() { - return default.to_path_buf(); - } - let path = std::path::PathBuf::from(value); - if path.is_absolute() { - path - } else { - config_file - .parent() - .expect("absolute configuration file has a parent") - .join(path) - } -} diff --git a/iota-installer/Cargo.toml b/iota-installer/Cargo.toml deleted file mode 100644 index 755c8f7..0000000 --- a/iota-installer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "iota-installer" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1" -tempfile = "3" -zip = "6" -serde_json = "1" -iota-paths = { path = "../iota-paths" } diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs deleted file mode 100644 index 0de2518..0000000 --- a/iota-installer/src/lib.rs +++ /dev/null @@ -1,183 +0,0 @@ -use anyhow::{Context, Result, bail}; -use std::{fs, io, path::Path, process::Command}; -use tempfile::tempdir; -use zip::ZipArchive; - -const REQUIRED: &[&str] = &[ - "bin/iota", - "bin/iota-daemon", - "bin/iota-updater", - "systemd/iota-daemon.service", - "systemd/iota-daemon.socket", - "systemd/sysusers.d/iota.conf", - "systemd/iota-update.service", - "systemd/iota-update.timer", - "manifest.json", -]; - -pub fn install_linux_bundle(bundle: &Path) -> Result<()> { - install_linux_bundle_with_operator(bundle, None) -} - -pub fn bootstrap_linux_bundle(bundle: &Path, operator: Option<&str>) -> Result<()> { - install_linux_bundle_with_operator(bundle, operator) -} - -pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> { - if std::env::consts::OS != "linux" { - bail!("Linux systemd bundles are not supported on this platform"); - } - let staging = tempdir().context("create installer staging directory")?; - let file = fs::File::open(bundle).context("open release bundle")?; - let mut archive = ZipArchive::new(file).context("read release bundle")?; - for name in REQUIRED { - let mut entry = archive - .by_name(name) - .with_context(|| format!("bundle is missing {name}"))?; - let output = staging.path().join(name); - if let Some(parent) = output.parent() { - fs::create_dir_all(parent)?; - } - let mut out = fs::File::create(&output)?; - io::copy(&mut entry, &mut out)?; - } - - install( - &staging.path().join("bin/iota"), - &format!( - "{}/versions/{}/bin/iota", - iota_paths::install_root().display(), - product_version(staging.path()) - ), - "0755", - )?; - install( - &staging.path().join("bin/iota-daemon"), - &format!( - "{}/versions/{}/bin/iota-daemon", - iota_paths::install_root().display(), - product_version(staging.path()) - ), - "0755", - )?; - let version_dir = format!( - "{}/versions/{}", - iota_paths::install_root().display(), - product_version(staging.path()) - ); - if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() { - bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon"); - } - install( - &staging.path().join("bin/iota-updater"), - &format!( - "{}/versions/{}/bin/iota-updater", - iota_paths::install_root().display(), - product_version(staging.path()) - ), - "0755", - )?; - for unit in [ - "iota-daemon.service", - "iota-daemon.socket", - "iota-update.service", - "iota-update.timer", - ] { - install( - &staging.path().join("systemd").join(unit), - &format!("/usr/local/lib/systemd/system/{unit}"), - "0644", - )?; - } - install( - &staging.path().join("systemd/sysusers.d/iota.conf"), - "/etc/sysusers.d/iota.conf", - "0644", - )?; - run( - "ln", - &[ - "-sfn", - &version_dir, - &iota_paths::current_version_link().to_string_lossy(), - ], - )?; - run( - "ln", - &[ - "-sfn", - &format!("{}/current/bin/iota", iota_paths::install_root().display()), - "/usr/local/bin/iota", - ], - )?; - run( - "ln", - &[ - "-sfn", - &format!( - "{}/current/bin/iota-daemon", - iota_paths::install_root().display() - ), - "/usr/local/libexec/iota/iota-daemon", - ], - )?; - run("systemd-sysusers", &[])?; - for directory in ["/var/lib/iota", "/var/cache/iota", "/var/log/iota"] { - run( - "install", - &["-d", "-m", "0750", "-o", "iota", "-g", "iota", directory], - )?; - } - if let Some(operator) = operator { - run("usermod", &["-aG", "iota-operators", operator])?; - } else { - eprintln!("To grant socket access, run: usermod -aG iota-operators USER"); - eprintln!( - "A new login session is required before supplementary group membership is visible." - ); - } - run("systemctl", &["daemon-reload"])?; - run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; - run("systemctl", &["is-active", "iota-daemon.socket"])?; - run("systemctl", &["is-enabled", "iota-daemon.socket"])?; - let socket = iota_paths::socket_path(iota_paths::Scope::System); - if !socket.exists() { - bail!( - "systemd socket is active but {} was not created", - socket.display() - ); - } - Ok(()) -} - -fn product_version(staging: &Path) -> String { - fs::read_to_string(staging.join("manifest.json")) - .ok() - .and_then(|value| serde_json::from_str::(&value).ok()) - .and_then(|value| { - value - .get("product_version") - .and_then(|v| v.as_str()) - .map(str::to_owned) - }) - .unwrap_or_else(|| "unversioned".into()) -} - -fn install(source: &Path, destination: &str, mode: &str) -> Result<()> { - run( - "install", - &["-D", "-m", mode, &source.to_string_lossy(), destination], - ) -} - -fn run(program: &str, args: &[&str]) -> Result<()> { - let status = Command::new(program) - .args(args) - .status() - .with_context(|| format!("run {program}"))?; - if status.success() { - Ok(()) - } else { - bail!("{program} failed; run the installer as root") - } -} diff --git a/iota-ipc/Cargo.toml b/iota-ipc/Cargo.toml deleted file mode 100644 index 8fb9e01..0000000 --- a/iota-ipc/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "iota-ipc" -version = "0.1.0" -edition = "2024" - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1.53.1", features = ["io-util", "macros", "rt"] } diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs deleted file mode 100644 index acf72b1..0000000 --- a/iota-ipc/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -pub mod protocol; -pub mod text_commands; -pub mod transport; - -pub use protocol::{ - ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, - ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, - ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase, - LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, - OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, - SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, - UpdateStatusResponse, UserDetailResponse, UserSummary, -}; -pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; - -/// Current IPC protocol version. -pub const PROTOCOL_VERSION: u16 = 2; -/// Minimum protocol version this daemon understands. -pub const MIN_PROTOCOL_VERSION: u16 = 2; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs deleted file mode 100644 index 4006583..0000000 --- a/iota-ipc/src/protocol.rs +++ /dev/null @@ -1,515 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// IPC credentials are supplied by the interactive CLI, never a daemon-side -/// path lookup. Debug is deliberately redacted because command routing logs -/// the request value. -#[derive(Clone, Deserialize, Serialize)] -pub struct SecretString(pub String); - -impl std::fmt::Debug for SecretString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -// --------------------------------------------------------------------------- -// Client → Daemon -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum ClientMessage { - Hello { - supported_versions: Vec, - }, - Subscribe { - log_classes: Vec, - metric_interval_ms: Option, - }, - Request(RequestEnvelope), - Ping { - seq: u64, - }, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RequestEnvelope { - pub request_id: u64, - pub protocol_version: u16, - pub request: LocalRequest, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum LocalRequest { - GetStatus, - ListTasks, - ListUsers, - CreateUser { - username: String, - }, - AttachUserFromTu { - credential: SecretString, - }, - PurgeUserData { - user_id: i64, - }, - ReleaseUser { - user_id: i64, - }, - CompleteDeleteUser { - user_id: i64, - credential: Option, - }, - /// Retained only to return an actionable deprecation error to old IPC - /// clients. It must never select lifecycle semantics implicitly. - RemoveUser { - user_id: i64, - }, - ReconnectOmikron, - RotateIotaIdentity, - RequestProcessExit { - intent: ExitIntent, - }, - GetDaemonStatus, - #[serde(skip)] - RestartDaemon, - #[serde(skip)] - StopDaemon, - GetConfig, - SetConfig { - key: String, - value: String, - }, - ReloadConfig, - GetOmikronStatus, - ListComponents, - GetUser { - user_id: i64, - }, - ImportUser { - username: String, - }, - GetLogs { - limit: usize, - }, - CheckUpdate, - ListCommunities, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum IpcRole { - Read, - Operate, - Admin, -} - -impl IpcRole { - pub fn allows(self, required: IpcRole) -> bool { - matches!( - (self, required), - (IpcRole::Admin, _) - | (IpcRole::Operate, IpcRole::Operate | IpcRole::Read) - | (IpcRole::Read, IpcRole::Read) - ) - } -} - -impl LocalRequest { - /// Return the minimum authenticated local role required to execute a - /// request. New request variants must be assigned explicitly here. - pub fn required_role(&self) -> IpcRole { - match self { - Self::GetStatus - | Self::ListTasks - | Self::ListUsers - | Self::GetDaemonStatus - | Self::GetOmikronStatus - | Self::ListComponents - | Self::GetUser { .. } - | Self::GetLogs { .. } - | Self::CheckUpdate - | Self::ListCommunities => IpcRole::Read, - - Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, - - Self::CreateUser { .. } - | Self::AttachUserFromTu { .. } - | Self::PurgeUserData { .. } - | Self::ReleaseUser { .. } - | Self::CompleteDeleteUser { .. } - | Self::RemoveUser { .. } - | Self::RotateIotaIdentity - | Self::RequestProcessExit { .. } - | Self::RestartDaemon - | Self::StopDaemon - | Self::GetConfig - | Self::SetConfig { .. } - | Self::ImportUser { .. } => IpcRole::Admin, - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ExitIntent { - Stop, - Restart, -} - -// --------------------------------------------------------------------------- -// Daemon → Client -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum DaemonMessage { - HelloAck(HelloAck), - /// Confirms that the server has installed this connection's subscription. - Subscribed, - LogEntry(LogEntry), - StateUpdate(StateSnapshot), - MetricSample(MetricSample), - Response(ResponseEnvelope), - Pong { - seq: u64, - }, - LifecycleEvent(LifecycleEvent), - Gap { - skipped: u64, - }, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct HelloAck { - pub protocol_version: u16, - pub daemon_version: String, - pub instance_id: String, - pub startup_phase: StartupPhase, - pub capabilities: Vec, - #[serde(default)] - pub lifecycle: LifecyclePhase, - #[serde(default)] - pub health: HealthStatus, - #[serde(default)] - pub deployment_mode: DeploymentMode, - #[serde(default)] - pub supervisor: SupervisorKind, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ResponseEnvelope { - pub request_id: u64, - pub result: ResponseResult, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum ResponseResult { - Ok(ResponsePayload), - Error(IpcErrorCode), -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum ResponsePayload { - Status(StatusResponse), - Tasks(Vec), - Users(Vec), - UserCreated { - user_id: i64, - username: String, - }, - /// Retained only for wire compatibility. New lifecycle code never emits it. - UserRemoved { - user_id: i64, - }, - UserDataPurged { - user_id: i64, - }, - Acknowledged { - message: String, - }, - DaemonStatus(DaemonStatusResponse), - Config(ConfigResponse), - OmikronStatus(OmikronStatusResponse), - Components(Vec), - UserDetail(UserDetailResponse), - LogEntries(LogEntriesResponse), - UpdateStatus(UpdateStatusResponse), - Communities(Vec), -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ConfigResponse { - pub yaml: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct OmikronStatusResponse { - pub connected: bool, - pub iota_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ComponentStatusResponse { - pub id: ComponentId, - pub status: HealthStatus, - pub message: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UserDetailResponse { - pub user_id: i64, - pub username: String, - pub display_name: Option, - pub created_at: i64, - pub trusted_apps: Vec, - pub state: LocalUserState, - pub data_present: bool, - pub credential_present: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct LogEntriesResponse { - pub entries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UpdateStatusResponse { - pub available: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CommunitySummary { - pub name: String, - pub title: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct StatusResponse { - pub phase: String, - pub tasks: Vec, - pub degraded_reason: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TaskSummary { - pub name: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UserSummary { - pub user_id: i64, - pub username: String, - pub state: LocalUserState, - pub data_present: bool, - pub credential_present: bool, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum LocalUserState { - Managed, - Released, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DaemonStatusResponse { - pub formatted: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum IpcErrorCode { - InvalidRequest, - NotFound, - Conflict, - StorageFailure, - OmikronUnavailable, - UnsupportedVersion, - NotReady, - Disconnected, - Timeout, - Cancelled, - Unauthorized, - InternalFailure, -} - -impl std::fmt::Display for IpcErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::InvalidRequest => "the daemon rejected the request", - Self::NotFound => "the requested resource was not found", - Self::Conflict => "the request conflicts with current daemon state", - Self::StorageFailure => "the daemon could not access local storage", - Self::OmikronUnavailable => "Omikron is unavailable", - Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible", - Self::NotReady => "the daemon is not ready yet", - Self::Disconnected => "the daemon connection was lost", - Self::Timeout => "the daemon did not respond in time", - Self::Cancelled => "the daemon cancelled the request", - Self::Unauthorized => { - "the daemon denied this operation because the IPC account lacks the required role" - } - Self::InternalFailure => "the daemon encountered an internal failure", - }) - } -} - -#[cfg(test)] -mod error_tests { - use super::IpcErrorCode; - - #[test] - fn error_codes_have_operator_facing_messages() { - assert_eq!( - IpcErrorCode::NotReady.to_string(), - "the daemon is not ready yet" - ); - assert!( - !IpcErrorCode::InternalFailure - .to_string() - .contains("InternalFailure") - ); - assert!( - IpcErrorCode::Unauthorized - .to_string() - .contains("required role") - ); - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum LifecycleEvent { - StateChanged(ConnectionStatus), - Shutdown { reason: String }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ConnectionStatus { - Connected, - Reconnecting, - Degraded, - Disconnected, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StartupPhase { - Starting, - MigratingStorage, - LoadingUsers, - StartingServices, - Ready, - Degraded, - Stopping, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LifecyclePhase { - #[default] - Starting, - Ready, - Stopping, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum HealthStatus { - #[default] - Healthy, - Degraded, - Failed, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ComponentId { - Storage, - Ipc, - Omikron, - Web, - Updater, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentMode { - SessionChild, - UiAutoStart, - UserService, - SystemSocketActivated, - SystemAlwaysOn, - #[default] - External, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SupervisorKind { - #[default] - None, - IotaUi, - Systemd, - External, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ComponentHealth { - pub status: HealthStatus, - pub message: Option, - pub changed_at_ms: u128, -} - -impl Default for StartupPhase { - fn default() -> Self { - Self::Starting - } -} - -// --------------------------------------------------------------------------- -// Shared types -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct LogEntry { - pub timestamp_ms: u128, - pub sender: String, - pub message: String, - pub is_error: bool, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct StateSnapshot { - pub cpu: Vec<(f64, f64)>, - pub ram: Vec<(f64, f64)>, - pub ping: Vec<(f64, f64)>, - pub net_up: Vec<(f64, f64)>, - pub net_down: Vec<(f64, f64)>, - pub sys_info: String, - #[serde(default)] - pub startup_phase: StartupPhase, - #[serde(default)] - pub degraded_reason: Option, - #[serde(default)] - pub lifecycle: LifecyclePhase, - #[serde(default)] - pub startup_step: Option, - #[serde(default)] - pub overall_health: HealthStatus, - #[serde(default)] - pub components: std::collections::BTreeMap, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct MetricSample { - pub cpu: Option, - pub ram: Option, - pub ping: Option, - pub net_up: Option, - pub net_down: Option, -} diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs deleted file mode 100644 index c6daa2b..0000000 --- a/iota-ipc/src/text_commands.rs +++ /dev/null @@ -1,334 +0,0 @@ -use crate::LocalRequest; - -pub const COMMANDS: &[&str] = &[ - "status", - "tasks", - "users list", - "users show ", - "users add ", - "users remove ", - "users import ", - "omikron status", - "reconnect", - "identity rotate", - "daemon status", - "config get", - "config set ", - "config reload", - "health", - "components", - "logs", - "update check", - "community list", - "restart", - "stop", -]; - -pub fn completions(prefix: &str) -> Vec<&'static str> { - let normalized = prefix.trim_start_matches('/'); - COMMANDS - .iter() - .copied() - .filter(|command| command.starts_with(normalized)) - .collect() -} - -pub fn validation_error(line: &str) -> Option { - let normalized = line.trim_start_matches('/').trim(); - if normalized == "help" || parse(normalized).is_some() { - None - } else { - Some(format!( - "Unknown command `{normalized}`. Use /help or Tab completion." - )) - } -} - -/// Parse a text command string into a typed IPC request. -/// -/// Both the CLI console and the TUI command palette use this single parser. -/// Commands are case-insensitive and support an optional leading `/`. -pub fn parse(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["status"] => Some(LocalRequest::GetStatus), - ["tasks"] => Some(LocalRequest::ListTasks), - ["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers), - ["user" | "users", "show", id_str] => { - let user_id = id_str.parse::().ok()?; - Some(LocalRequest::GetUser { user_id }) - } - ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user" | "users", "remove", id_str] => { - let user_id = id_str.parse::().ok()?; - Some(LocalRequest::RemoveUser { user_id }) - } - ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { - username: username.to_string(), - }), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: crate::ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: crate::ExitIntent::Stop, - }), - ["daemon", "status"] => Some(LocalRequest::GetDaemonStatus), - ["config", "get"] => Some(LocalRequest::GetConfig), - ["config", "set", key, value] => Some(LocalRequest::SetConfig { - key: key.to_string(), - value: value.to_string(), - }), - ["config", "reload"] => Some(LocalRequest::ReloadConfig), - ["omikron", "status"] => Some(LocalRequest::GetOmikronStatus), - ["health"] => Some(LocalRequest::ListComponents), - ["components"] => Some(LocalRequest::ListComponents), - ["logs"] => Some(LocalRequest::GetLogs { limit: 100 }), - ["update", "check"] => Some(LocalRequest::CheckUpdate), - ["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_status() { - assert!(matches!(parse("status"), Some(LocalRequest::GetStatus))); - } - - #[test] - fn parses_tasks() { - assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks))); - } - - #[test] - fn parses_user_list_shortcuts() { - assert!(matches!(parse("users"), Some(LocalRequest::ListUsers))); - assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers))); - } - - #[test] - fn parses_user_add() { - let req = parse("user add alice").unwrap(); - match req { - LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), - _ => panic!("expected CreateUser"), - } - } - - #[test] - fn accepts_the_headless_cli_user_vocabulary() { - assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); - assert!(matches!( - parse("users add alice"), - Some(LocalRequest::CreateUser { .. }) - )); - assert!(matches!( - parse("users remove 42"), - Some(LocalRequest::RemoveUser { user_id: 42 }) - )); - assert!(matches!( - parse("identity rotate"), - Some(LocalRequest::RotateIotaIdentity) - )); - } - - #[test] - fn parses_user_remove_by_id() { - let req = parse("user remove 42").unwrap(); - match req { - LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected RemoveUser"), - } - } - - #[test] - fn user_remove_requires_numeric_id() { - assert!(parse("user remove alice").is_none()); - } - - #[test] - fn parses_reconnect() { - assert!(matches!( - parse("reconnect"), - Some(LocalRequest::ReconnectOmikron) - )); - } - - #[test] - fn parses_regenerate_keys() { - assert!(matches!( - parse("regenerate keys"), - Some(LocalRequest::RotateIotaIdentity) - )); - } - - #[test] - fn parses_restart_aliases() { - assert!(matches!( - parse("restart"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - assert!(matches!( - parse("reload"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - } - - #[test] - fn parses_stop_aliases() { - assert!(matches!( - parse("stop"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - assert!(matches!( - parse("shutdown"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - } - - #[test] - fn parses_daemon_status() { - assert!(matches!( - parse("daemon status"), - Some(LocalRequest::GetDaemonStatus) - )); - } - - #[test] - fn parses_config_get() { - assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig))); - } - - #[test] - fn parses_config_reload() { - assert!(matches!( - parse("config reload"), - Some(LocalRequest::ReloadConfig) - )); - } - - #[test] - fn parses_omikron_status() { - assert!(matches!( - parse("omikron status"), - Some(LocalRequest::GetOmikronStatus) - )); - } - - #[test] - fn parses_components() { - assert!(matches!( - parse("components"), - Some(LocalRequest::ListComponents) - )); - } - - #[test] - fn parses_health() { - assert!(matches!( - parse("health"), - Some(LocalRequest::ListComponents) - )); - } - - #[test] - fn parses_users_show() { - let req = parse("users show 42").unwrap(); - match req { - LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected GetUser"), - } - } - - #[test] - fn user_show_requires_numeric_id() { - assert!(parse("users show alice").is_none()); - } - - #[test] - fn parses_config_set() { - let req = parse("config set port 8080").unwrap(); - match req { - LocalRequest::SetConfig { key, value } => { - assert_eq!(key, "port"); - assert_eq!(value, "8080"); - } - _ => panic!("expected SetConfig"), - } - } - - #[test] - fn parses_logs() { - assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. }))); - } - - #[test] - fn parses_update_check() { - assert!(matches!( - parse("update check"), - Some(LocalRequest::CheckUpdate) - )); - } - - #[test] - fn parses_community_list() { - assert!(matches!( - parse("community list"), - Some(LocalRequest::ListCommunities) - )); - } - - #[test] - fn parses_communities_alias() { - assert!(matches!( - parse("communities"), - Some(LocalRequest::ListCommunities) - )); - } - - #[test] - fn parses_users_import() { - let req = parse("users import alice").unwrap(); - match req { - LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), - _ => panic!("expected ImportUser"), - } - } - - #[test] - fn parses_with_slash_prefix() { - assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus))); - assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks))); - } - - #[test] - fn unknown_returns_none() { - assert!(parse("nonexistent").is_none()); - } - - #[test] - fn completion_is_prefix_based_and_deterministic() { - assert_eq!(completions("identity r"), vec!["identity rotate"]); - assert_eq!(completions("/users a"), vec!["users add "]); - assert!(completions("definitely-unknown").is_empty()); - } - - #[test] - fn validation_distinguishes_help_and_unknown_commands() { - assert_eq!(validation_error("/help"), None); - assert!(validation_error("status").is_none()); - assert!( - validation_error("statuz") - .unwrap() - .contains("Unknown command") - ); - } -} diff --git a/iota-ipc/src/transport.rs b/iota-ipc/src/transport.rs deleted file mode 100644 index 6a4382f..0000000 --- a/iota-ipc/src/transport.rs +++ /dev/null @@ -1,96 +0,0 @@ -use serde::Serialize; -use serde::de::DeserializeOwned; -use std::io::{Error, ErrorKind, Result}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; - -/// Maximum encoded payload size for a single IPC frame. -/// -/// This is a wire-level contract shared by both sides of the connection. -pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024; - -/* Length-prefixing preserves message boundaries on a byte stream and bounds - * allocations before JSON is deserialized. */ -pub async fn write_msg(writer: &mut W, message: &T) -> Result<()> -where - W: AsyncWrite + Unpin, - T: Serialize, -{ - let payload = - serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?; - if payload.len() > MAX_MESSAGE_SIZE { - return Err(Error::new( - ErrorKind::InvalidData, - "IPC message exceeds limit", - )); - } - let len = u32::try_from(payload.len()) - .map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?; - writer.write_u32(len).await?; - writer.write_all(&payload).await?; - writer.flush().await -} - -pub async fn read_msg(reader: &mut R) -> Result -where - R: AsyncRead + Unpin, - T: DeserializeOwned, -{ - let len = reader.read_u32().await? as usize; - if len > MAX_MESSAGE_SIZE { - return Err(Error::new( - ErrorKind::InvalidData, - "IPC message exceeds limit", - )); - } - let mut payload = vec![0; len]; - reader.read_exact(&mut payload).await?; - serde_json::from_slice(&payload).map_err(|error| Error::new(ErrorKind::InvalidData, error)) -} - -#[cfg(test)] -mod tests { - use super::{MAX_MESSAGE_SIZE, read_msg, write_msg}; - use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope}; - - #[tokio::test] - async fn round_trips_framed_messages() { - let (mut writer, mut reader) = tokio::io::duplex(1024); - let message = ClientMessage::Request(RequestEnvelope { - request_id: 4, - protocol_version: 2, - request: LocalRequest::GetStatus, - }); - write_msg(&mut writer, &message) - .await - .expect("write succeeds"); - let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds"); - assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4)); - } - - #[tokio::test] - async fn write_rejects_message_above_frame_limit() { - let (mut writer, _reader) = tokio::io::duplex(MAX_MESSAGE_SIZE + 16); - let message = "x".repeat(MAX_MESSAGE_SIZE + 1); - - let error = write_msg(&mut writer, &message) - .await - .expect_err("oversized payload must be rejected before framing"); - - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - assert!(error.to_string().contains("exceeds limit")); - } - - #[tokio::test] - async fn read_rejects_frame_above_limit_before_allocating_payload() { - let (mut writer, mut reader) = tokio::io::duplex(16); - tokio::io::AsyncWriteExt::write_u32(&mut writer, (MAX_MESSAGE_SIZE + 1) as u32) - .await - .expect("length prefix write succeeds"); - - let error = read_msg::<_, ClientMessage>(&mut reader) - .await - .expect_err("oversized frame must be rejected"); - - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - } -} diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml deleted file mode 100644 index 5287ecb..0000000 --- a/iota-logger/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "iota-logger" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-paths = { path = "../iota-paths" } -iota-state = { path = "../iota-state" } -iota-util = { path = "../iota-util" } - -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } - -ratatui = "0.30.0" -json = "0.12.4" -once_cell = "1.21.4" -tokio = { version = "1.50.0", features = ["sync"] } diff --git a/iota-paths/Cargo.toml b/iota-paths/Cargo.toml deleted file mode 100644 index 00a1b19..0000000 --- a/iota-paths/Cargo.toml +++ /dev/null @@ -1,4 +0,0 @@ -[package] -name = "iota-paths" -version = "0.1.0" -edition = "2024" diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs deleted file mode 100644 index 0083012..0000000 --- a/iota-paths/src/lib.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Platform and deployment aware locations used by Iota. -//! -//! This module deliberately keeps environment handling in one place. In -//! particular, an override is never interpreted relative to the process -//! working directory. -use std::env; -use std::fmt; -use std::path::{Path, PathBuf}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Scope { - User, - System, -} - -/// Compatibility name retained for callers which have not yet been migrated. -pub type SocketScope = Scope; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum IpcEndpoint { - UnixSocket(PathBuf), - WindowsPipe(String), -} - -#[derive(Debug)] -pub enum PathError { - MissingPlatformDirectory(&'static str), - MissingRequiredOverride(&'static str), - EmptyOverride(&'static str), - RelativeOverride { - variable: &'static str, - value: PathBuf, - }, - InvalidPipeName(String), - UnsupportedScope, -} -impl fmt::Display for PathError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"), - Self::MissingRequiredOverride(name) => write!(f, "{name} must be set"), - Self::EmptyOverride(name) => write!(f, "{name} must not be empty"), - Self::RelativeOverride { variable, value } => { - write!(f, "{variable} must be absolute, got {}", value.display()) - } - Self::InvalidPipeName(name) => write!(f, "invalid Windows pipe name: {name}"), - Self::UnsupportedScope => write!(f, "this path scope is unsupported on this platform"), - } - } -} -impl std::error::Error for PathError {} - -#[derive(Clone, Debug)] -pub struct IotaPaths { - pub scope: Scope, - pub config_dir: PathBuf, - pub config_file: PathBuf, - pub state_dir: PathBuf, - pub storage_dir: PathBuf, - pub identity_dir: PathBuf, - pub cache_dir: PathBuf, - pub runtime_dir: Option, - pub log_dir: PathBuf, - /// Directory containing static web assets (not its parent). - pub asset_dir: PathBuf, - pub install_root: PathBuf, - pub ipc_endpoint: IpcEndpoint, -} - -impl IotaPaths { - pub fn resolve(scope: Scope) -> Result { - let defaults = Defaults::for_scope(scope)?; - let data_root = if scope == Scope::User { - absolute_env("IOTA_DATA_ROOT")? - } else { - None - }; - let config_dir = override_first(&["IOTA_CONFIG_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("config"))) - .unwrap_or(defaults.config_dir); - // IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it - // exactly like every other override; do not hide an invalid value. - let state_dir = override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("state"))) - .unwrap_or(defaults.state_dir); - let cache_dir = override_first(&["IOTA_CACHE_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("cache"))) - .unwrap_or(defaults.cache_dir); - let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("runtime"))) - .or(defaults.runtime_dir); - let log_dir = override_first(&["IOTA_LOG_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("logs"))) - .unwrap_or(defaults.log_dir); - let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])? - .or_else(|| data_root.as_ref().map(|root| root.join("web"))) - .unwrap_or(defaults.asset_dir); - let install_root = override_first(&["IOTA_INSTALL_ROOT"])? - .or_else(|| data_root.as_ref().map(|root| root.join("bin"))) - .unwrap_or(defaults.install_root); - let config_file = override_first(&["IOTA_CONFIG_FILE"])? - .unwrap_or_else(|| config_dir.join("config.yaml")); - let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint, data_root.as_deref())?; - let runtime_dir = runtime_dir.or_else(|| match &ipc_endpoint { - IpcEndpoint::UnixSocket(path) => path.parent().map(Path::to_path_buf), - IpcEndpoint::WindowsPipe(_) => None, - }); - Ok(Self { - scope, - config_dir, - config_file, - storage_dir: state_dir.join("storage"), - identity_dir: state_dir.join("identity"), - state_dir, - cache_dir, - runtime_dir, - log_dir, - asset_dir, - install_root, - ipc_endpoint, - }) - } - - pub fn database_file(&self) -> PathBuf { - self.storage_dir.join("messages.sqlite3") - } - pub fn keyring_file(&self) -> PathBuf { - self.identity_dir.join("iota.mk") - } - pub fn update_staging_dir(&self) -> PathBuf { - self.cache_dir.join("updates/staging") - } - pub fn update_status_file(&self) -> PathBuf { - self.state_dir.join("update-status.json") - } - pub fn update_lock_file(&self) -> Result { - self.runtime_dir - .as_ref() - .map(|p| p.join("update.lock")) - .ok_or(PathError::MissingPlatformDirectory("runtime directory")) - } - pub fn daemon_lock_file(&self) -> Result { - self.runtime_dir - .as_ref() - .map(|p| p.join("daemon.lock")) - .ok_or(PathError::MissingPlatformDirectory("runtime directory")) - } - pub fn prepare_writable_directories(&self) -> std::io::Result<()> { - for directory in [ - &self.state_dir, - &self.storage_dir, - &self.identity_dir, - &self.cache_dir, - &self.log_dir, - ] { - create_directory(directory, self.scope == Scope::User).map_err(|error| { - std::io::Error::new( - error.kind(), - format!("cannot prepare {}: {error}", directory.display()), - ) - })?; - } - if let Some(runtime) = &self.runtime_dir { - create_directory(runtime, self.scope == Scope::User).map_err(|error| { - std::io::Error::new( - error.kind(), - format!("cannot prepare {}: {error}", runtime.display()), - ) - })?; - } - Ok(()) - } - - /// Move the pre-v2 resources that were all placed directly below the - /// state root. This is deliberately idempotent: an existing destination - /// is never overwritten and the marker is only written after the moves. - pub fn migrate_legacy_layout(&self) -> std::io::Result<()> { - let marker = self.state_dir.join("path-layout-v2.json"); - if marker.exists() { - return Ok(()); - } - move_if_absent(&self.state_dir.join("config.yaml"), &self.config_file)?; - move_if_absent(&self.state_dir.join("certs"), &self.config_dir.join("tls"))?; - for suffix in [ - "messages.sqlite3", - "messages.sqlite3-wal", - "messages.sqlite3-shm", - ] { - move_if_absent(&self.state_dir.join(suffix), &self.storage_dir.join(suffix))?; - } - for name in ["users", "communities"] { - move_if_absent(&self.state_dir.join(name), &self.storage_dir.join(name))?; - } - move_if_absent(&self.state_dir.join("iota.mk"), &self.keyring_file())?; - move_if_absent( - &self.state_dir.join("update-staging"), - &self.update_staging_dir(), - )?; - // Runtime objects must not survive a layout migration or reboot. - for name in ["update.lock", "iota.sock", "iota.sock.lock"] { - let path = self.state_dir.join(name); - if path.is_file() || path.is_symlink() { - let _ = std::fs::remove_file(path); - } - } - std::fs::create_dir_all(&self.state_dir)?; - std::fs::write(marker, "{\"version\":2}\n") - } -} - -fn move_if_absent(source: &Path, destination: &Path) -> std::io::Result<()> { - if !source.exists() || destination.exists() { - return Ok(()); - } - let metadata = std::fs::symlink_metadata(source)?; - if metadata.file_type().is_symlink() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("refusing symlink migration source {}", source.display()), - )); - } - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent)?; - } - match std::fs::rename(source, destination) { - Ok(()) => Ok(()), - Err(error) if error.raw_os_error() == Some(libc_exdev()) => { - copy_recursively(source, destination)?; - if source.is_dir() { - std::fs::remove_dir_all(source) - } else { - std::fs::remove_file(source) - } - } - Err(error) => Err(error), - } -} - -// EXDEV is stable on Unix. A literal is used on non-Unix where the fallback -// copy is harmlessly skipped because rename normally remains on one volume. -#[cfg(unix)] -fn libc_exdev() -> i32 { - 18 -} -#[cfg(not(unix))] -fn libc_exdev() -> i32 { - -1 -} -fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> { - if source.is_dir() { - std::fs::create_dir_all(destination)?; - for entry in std::fs::read_dir(source)? { - let entry = entry?; - copy_recursively(&entry.path(), &destination.join(entry.file_name()))?; - } - Ok(()) - } else { - std::fs::copy(source, destination).map(|_| ()) - } -} - -struct Defaults { - config_dir: PathBuf, - state_dir: PathBuf, - cache_dir: PathBuf, - runtime_dir: Option, - log_dir: PathBuf, - asset_dir: PathBuf, - install_root: PathBuf, - ipc_endpoint: Option, -} -impl Defaults { - fn for_scope(scope: Scope) -> Result { - match scope { - Scope::System => { - #[cfg(target_os = "linux")] - { - Ok(Self { - config_dir: "/etc/iota".into(), - state_dir: "/var/lib/iota".into(), - cache_dir: "/var/cache/iota".into(), - runtime_dir: Some("/run/iota".into()), - log_dir: "/var/log/iota".into(), - asset_dir: "/usr/local/share/iota/web".into(), - install_root: "/usr/local/libexec/iota".into(), - ipc_endpoint: Some(IpcEndpoint::UnixSocket("/run/iota/iota.sock".into())), - }) - } - #[cfg(not(target_os = "linux"))] - { - Err(PathError::UnsupportedScope) - } - } - Scope::User => user_defaults(), - } - } -} - -#[cfg(unix)] -fn user_defaults() -> Result { - let home = - absolute_env("HOME")?.ok_or(PathError::MissingPlatformDirectory("home directory"))?; - let config_base = xdg_or_home("XDG_CONFIG_HOME", &home, ".config")?; - let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?; - let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?; - let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?; - Ok(Defaults { - config_dir: config_base.join("iota"), - state_dir: state_base.join("iota"), - cache_dir: cache_base.join("iota"), - runtime_dir: None, - log_dir: state_base.join("iota/logs"), - asset_dir: data_base.join("iota/web"), - install_root: data_base.join("iota/bin"), - ipc_endpoint: None, - }) -} -#[cfg(windows)] -fn user_defaults() -> Result { - let config = absolute_env("APPDATA")? - .ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))? - .join("Tensamin/Iota/config"); - let local = absolute_env("LOCALAPPDATA")? - .ok_or(PathError::MissingPlatformDirectory("Local AppData"))? - .join("Tensamin/Iota"); - Ok(Defaults { - config_dir: config, - state_dir: local.join("state"), - cache_dir: local.join("cache"), - runtime_dir: None, - log_dir: local.join("logs"), - asset_dir: local.join("data"), - install_root: local.join("bin"), - ipc_endpoint: Some(IpcEndpoint::WindowsPipe( - r"\\.\pipe\Tensamin.Iota.User".into(), - )), - }) -} - -fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result { - Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback))) -} -fn absolute_env(name: &'static str) -> Result, PathError> { - let Some(value) = env::var_os(name) else { - return Ok(None); - }; - if value.is_empty() { - return Err(PathError::EmptyOverride(name)); - } - let path = PathBuf::from(value); - if !path.is_absolute() { - return Err(PathError::RelativeOverride { - variable: name, - value: path, - }); - } - Ok(Some(path)) -} -fn override_first(names: &[&'static str]) -> Result, PathError> { - for name in names { - if let Some(value) = absolute_env(name)? { - return Ok(Some(value)); - } - } - Ok(None) -} -fn resolve_ipc( - scope: Scope, - default: Option, - data_root: Option<&Path>, -) -> Result { - #[cfg(unix)] - { - if let Some(path) = absolute_env("IOTA_SOCKET")? { - return Ok(IpcEndpoint::UnixSocket(path)); - } - if let Some(root) = data_root { - return Ok(IpcEndpoint::UnixSocket(root.join("runtime/iota.sock"))); - } - if scope == Scope::User { - return Err(PathError::MissingRequiredOverride("IOTA_SOCKET")); - } - } - #[cfg(windows)] - { - if let Some(name) = env::var_os("IOTA_PIPE") { - let name = name.to_string_lossy().into_owned(); - if !name.starts_with(r"\\.\pipe\") { - return Err(PathError::InvalidPipeName(name)); - } - return Ok(IpcEndpoint::WindowsPipe(name)); - } - } - default.ok_or(PathError::UnsupportedScope) -} -fn create_directory(path: &Path, private: bool) -> std::io::Result<()> { - std::fs::create_dir_all(path)?; - #[cfg(unix)] - if private { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; - } - Ok(()) -} - -// Compatibility helpers. New code should resolve IotaPaths once and pass it -// to its dependencies instead of calling these independently. -pub fn data_dir() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .state_dir -} -pub fn config_dir() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .config_dir -} -pub fn socket_override() -> Option { - absolute_env("IOTA_SOCKET").ok().flatten() -} -pub fn socket_path(scope: SocketScope) -> PathBuf { - match IotaPaths::resolve(scope) - .expect("resolve Iota paths") - .ipc_endpoint - { - IpcEndpoint::UnixSocket(path) => path, - IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"), - } -} -pub fn socket_lock_path(scope: SocketScope) -> PathBuf { - IotaPaths::resolve(scope) - .expect("resolve Iota paths") - .daemon_lock_file() - .expect("runtime directory") -} -/// The compatibility installation helpers describe the machine installation, -/// not a user's data directory. Per-user launchers should keep an -/// `IotaPaths` instance and use its `install_root` directly. -pub fn install_root() -> PathBuf { - IotaPaths::resolve(Scope::System) - .expect("resolve Iota system paths") - .install_root -} -pub fn versions_dir() -> PathBuf { - install_root().join("versions") -} -pub fn current_version_link() -> PathBuf { - install_root().join("current") -} -pub fn updater_lock_path() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .update_lock_file() - .expect("runtime directory") -} -pub fn updater_status_path() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .update_status_file() -} -pub fn updater_staging_dir() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .update_staging_dir() -} -pub fn web_asset_dir() -> PathBuf { - IotaPaths::resolve(Scope::User) - .expect("resolve Iota user paths") - .asset_dir -} -pub fn daemon_executable() -> PathBuf { - absolute_env("IOTA_DAEMON_PATH") - .expect("valid IOTA_DAEMON_PATH") - .unwrap_or_else(|| install_root().join("current/bin/iota-daemon")) -} -pub fn updater_executable() -> PathBuf { - absolute_env("IOTA_UPDATER_PATH") - .expect("valid IOTA_UPDATER_PATH") - .unwrap_or_else(|| install_root().join("current/bin/iota-updater")) -} -pub fn daemon_endpoints() -> Vec { - vec![socket_path(Scope::User), socket_path(Scope::System)] -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{ - Mutex, - atomic::{AtomicU64, Ordering}, - }; - - static TEST_ID: AtomicU64 = AtomicU64::new(0); - static ENVIRONMENT: Mutex<()> = Mutex::new(()); - #[test] - fn system_layout_is_fhs() { - let p = IotaPaths::resolve(Scope::System).unwrap(); - assert_eq!(p.config_file, PathBuf::from("/etc/iota/config.yaml")); - assert_eq!( - p.database_file(), - PathBuf::from("/var/lib/iota/storage/messages.sqlite3") - ); - assert_eq!( - p.update_staging_dir(), - PathBuf::from("/var/cache/iota/updates/staging") - ); - } - - #[test] - fn migration_moves_state_resources_without_overwriting_destination() { - let root = std::env::temp_dir().join(format!( - "iota-paths-test-{}-{}", - std::process::id(), - TEST_ID.fetch_add(1, Ordering::Relaxed) - )); - let state = root.join("state"); - let config = root.join("config"); - let paths = IotaPaths { - scope: Scope::User, - config_dir: config.clone(), - config_file: config.join("config.yaml"), - storage_dir: state.join("storage"), - identity_dir: state.join("identity"), - cache_dir: root.join("cache"), - runtime_dir: Some(root.join("runtime")), - log_dir: state.join("logs"), - asset_dir: root.join("data/web"), - install_root: root.join("bin"), - ipc_endpoint: IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")), - state_dir: state.clone(), - }; - std::fs::create_dir_all(state.join("users")).unwrap(); - std::fs::write(state.join("messages.sqlite3"), b"db").unwrap(); - std::fs::write(state.join("config.yaml"), b"web: {}\n").unwrap(); - paths.migrate_legacy_layout().unwrap(); - assert!(paths.database_file().is_file()); - assert!(paths.storage_dir.join("users").is_dir()); - assert!(paths.config_file.is_file()); - assert!(state.join("path-layout-v2.json").is_file()); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn data_root_keeps_unmanaged_user_paths_together() { - let _guard = ENVIRONMENT.lock().unwrap(); - let root = std::env::temp_dir().join(format!( - "iota-data-root-test-{}-{}", - std::process::id(), - TEST_ID.fetch_add(1, Ordering::Relaxed) - )); - unsafe { - std::env::set_var("IOTA_DATA_ROOT", &root); - } - let paths = IotaPaths::resolve(Scope::User).unwrap(); - unsafe { - std::env::remove_var("IOTA_DATA_ROOT"); - } - - assert_eq!(paths.config_file, root.join("config/config.yaml")); - assert_eq!(paths.state_dir, root.join("state")); - assert_eq!(paths.cache_dir, root.join("cache")); - assert_eq!(paths.log_dir, root.join("logs")); - assert_eq!(paths.runtime_dir, Some(root.join("runtime"))); - assert_eq!( - paths.ipc_endpoint, - IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")) - ); - } -} diff --git a/iota-process-manager/Cargo.toml b/iota-process-manager/Cargo.toml deleted file mode 100644 index 6977698..0000000 --- a/iota-process-manager/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "iota-process-manager" -version = "0.1.0" -edition = "2024" - -[dependencies] -async-trait = "0.1" -tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] } - -[dev-dependencies] -libc = "0.2" -tempfile = "3" diff --git a/iota-process-manager/src/lib.rs b/iota-process-manager/src/lib.rs deleted file mode 100644 index 3512a90..0000000 --- a/iota-process-manager/src/lib.rs +++ /dev/null @@ -1,628 +0,0 @@ -use async_trait::async_trait; -use std::{ - fmt::{Display, Formatter}, - sync::Arc, -}; - -pub const PROCESS_MANAGER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct UnitStatus { - pub active: bool, - pub enabled: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StartupMode { - AlwaysOn, - SocketActivated, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProcessAction { - Start, - Stop, - Restart, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DetectedStartupMode { - AlwaysOn, - SocketActivated, - Disabled, - Conflicting, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DaemonStartupStatus { - pub service: UnitStatus, - pub socket: UnitStatus, - pub detected: DetectedStartupMode, -} - -impl DaemonStartupStatus { - pub fn classify(service: UnitStatus, socket: UnitStatus) -> Self { - let detected = match (service.enabled, socket.enabled) { - (true, false) => DetectedStartupMode::AlwaysOn, - (false, true) => DetectedStartupMode::SocketActivated, - (false, false) => DetectedStartupMode::Disabled, - (true, true) => DetectedStartupMode::Conflicting, - }; - Self { - service, - socket, - detected, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProcessManagerErrorKind { - CommandUnavailable, - PermissionDenied, - UnitMissing, - CommandFailed, - ParseFailed, - VerificationFailed, - TimedOut, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ProcessManagerError { - pub kind: ProcessManagerErrorKind, - message: String, -} -impl ProcessManagerError { - pub fn new(kind: ProcessManagerErrorKind, message: impl Into) -> Self { - Self { - kind, - message: message.into(), - } - } - pub fn kind(&self) -> ProcessManagerErrorKind { - self.kind - } -} -impl Display for ProcessManagerError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} -impl std::error::Error for ProcessManagerError {} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandOutput { - pub success: bool, - pub stdout: String, - pub stderr: String, -} - -#[async_trait] -pub trait CommandExecutor: Send + Sync { - async fn output( - &self, - program: &str, - args: &[&str], - ) -> Result; -} - -#[async_trait] -pub trait ProcessManager: Send + Sync { - fn name(&self) -> &'static str; - async fn unit_status(&self, unit: &str) -> Result; - async fn set_iota_startup_mode( - &self, - mode: StartupMode, - ) -> Result; - async fn iota_startup_status(&self) -> Result { - Ok(DaemonStartupStatus::classify( - self.unit_status("iota-daemon.service").await?, - self.unit_status("iota-daemon.socket").await?, - )) - } - async fn enable_startup( - &self, - mode: StartupMode, - ) -> Result { - self.set_iota_startup_mode(mode).await - } - async fn disable_startup(&self) -> Result { - self.set_iota_startup_mode(StartupMode::SocketActivated) - .await - } - async fn process_action( - &self, - action: ProcessAction, - ) -> Result { - let unit = "iota-daemon.service"; - match action { - ProcessAction::Start => self.unit_action(&["start", unit]).await?, - ProcessAction::Stop => self.unit_action(&["stop", unit]).await?, - ProcessAction::Restart => self.unit_action(&["restart", unit]).await?, - } - self.iota_startup_status().await - } - async fn unit_action(&self, _action: &[&str]) -> Result<(), ProcessManagerError> { - Err(ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - "process actions are unsupported", - )) - } -} - -pub async fn detect() -> Option> { - #[cfg(target_os = "linux")] - { - systemd::SystemdManager::detect() - .await - .map(|m| Arc::new(m) as Arc) - } - #[cfg(not(target_os = "linux"))] - { - None - } -} - -#[cfg(target_os = "linux")] -mod systemd { - use super::*; - use std::{path::Path, process::Stdio}; - use tokio::{io::AsyncRead, process::Command, time::timeout}; - - const SERVICE: &str = "iota-daemon.service"; - const SOCKET: &str = "iota-daemon.socket"; - const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"]; - const MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024; - - pub struct RealExecutor; - - async fn read_bounded(reader: R) -> std::io::Result> - where - R: AsyncRead + Unpin, - { - use tokio::io::AsyncReadExt; - - let mut output = Vec::new(); - reader - .take((MAX_COMMAND_OUTPUT_BYTES + 1) as u64) - .read_to_end(&mut output) - .await?; - if output.len() > MAX_COMMAND_OUTPUT_BYTES { - output.truncate(MAX_COMMAND_OUTPUT_BYTES); - } - Ok(output) - } - - async fn collect_output( - stdout: tokio::process::ChildStdout, - stderr: tokio::process::ChildStderr, - ) -> Result<(Vec, Vec), ProcessManagerError> { - let (stdout_result, stderr_result) = - tokio::join!(read_bounded(stdout), read_bounded(stderr)); - let stdout = stdout_result.map_err(|error| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - format!("stdout read failed: {error}"), - ) - })?; - let stderr = stderr_result.map_err(|error| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - format!("stderr read failed: {error}"), - ) - })?; - Ok((stdout, stderr)) - } - - impl RealExecutor { - async fn output_with_timeout( - &self, - program: &str, - args: &[&str], - process_timeout: std::time::Duration, - ) -> Result { - let mut child = Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|e| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandUnavailable, - format!("Could not run {program}: {e}"), - ) - })?; - - let stdout = child.stdout.take().ok_or_else(|| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - "command stdout pipe was not created", - ) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - "command stderr pipe was not created", - ) - })?; - let output_task = tokio::spawn(collect_output(stdout, stderr)); - - let status = match timeout(process_timeout, child.wait()).await { - Ok(result) => result.map_err(|e| { - ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string()) - })?, - Err(_) => { - // Keep the Child alive across the timeout. Explicitly - // terminate it and await wait() so the OS child is - // reaped before reporting the timeout. - let kill_error = child.start_kill().err(); - let wait_error = child.wait().await.err(); - output_task.abort(); - let _ = output_task.await; - - if let Some(error) = wait_error { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - format!("{program} timed out and could not be reaped: {error}"), - )); - } - let termination_detail = kill_error - .map(|error| format!("; termination request reported: {error}")) - .unwrap_or_default(); - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::TimedOut, - format!( - "{program} timed out after {} seconds{termination_detail}", - process_timeout.as_secs(), - ), - )); - } - }; - - let (stdout, stderr) = output_task.await.map_err(|error| { - ProcessManagerError::new( - ProcessManagerErrorKind::CommandFailed, - format!("command output task failed: {error}"), - ) - })??; - Ok(CommandOutput { - success: status.success(), - stdout: String::from_utf8_lossy(&stdout).into_owned(), - stderr: String::from_utf8_lossy(&stderr).into_owned(), - }) - } - } - - #[async_trait] - impl CommandExecutor for RealExecutor { - async fn output( - &self, - program: &str, - args: &[&str], - ) -> Result { - self.output_with_timeout(program, args, PROCESS_MANAGER_TIMEOUT) - .await - } - } - - pub struct SystemdManager { - executor: Arc, - service: &'static str, - socket: &'static str, - } - impl SystemdManager { - pub async fn detect() -> Option { - if !Path::new("/run/systemd/system").is_dir() { - return None; - } - let executor: Arc = Arc::new(RealExecutor); - let mut manager = executor - .output("systemctl", &["--version", &COMMON[0], &COMMON[1]]) - .await - .ok() - .filter(|r| r.success) - .map(|_| Self { - executor, - service: SERVICE, - socket: SOCKET, - })?; - if manager.status("iota.service").await.is_ok() { - manager.service = "iota.service"; - manager.socket = "iota.socket"; - } - Some(manager) - } - #[cfg(test)] - pub fn with_executor(executor: Arc) -> Self { - Self { - executor, - service: SERVICE, - socket: SOCKET, - } - } - async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> { - let mut args = COMMON.to_vec(); - args.extend_from_slice(action); - let output = self.executor.output("systemctl", &args).await?; - if output.success { - return Ok(()); - } - let detail = if output.stderr.trim().is_empty() { - output.stdout.trim() - } else { - output.stderr.trim() - }; - let kind = if detail.to_ascii_lowercase().contains("access denied") - || detail.to_ascii_lowercase().contains("permission denied") - { - ProcessManagerErrorKind::PermissionDenied - } else { - ProcessManagerErrorKind::CommandFailed - }; - Err(ProcessManagerError::new( - kind, - if detail.is_empty() { - format!("systemctl {} failed", action.join(" ")) - } else { - detail.to_owned() - }, - )) - } - async fn status(&self, unit: &str) -> Result { - let mut args = COMMON.to_vec(); - args.extend_from_slice(&[ - "show", - "--property=LoadState", - "--property=ActiveState", - "--property=UnitFileState", - "--value", - unit, - ]); - let output = self.executor.output("systemctl", &args).await?; - if !output.success { - let detail = if output.stderr.trim().is_empty() { - output.stdout.trim() - } else { - output.stderr.trim() - }; - let kind = if detail.to_ascii_lowercase().contains("denied") { - ProcessManagerErrorKind::PermissionDenied - } else { - ProcessManagerErrorKind::CommandFailed - }; - return Err(ProcessManagerError::new( - kind, - format!("systemctl could not inspect {unit}: {detail}"), - )); - } - let values: Vec<_> = output.stdout.lines().map(str::trim).collect(); - if values.len() < 3 { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::ParseFailed, - format!("systemctl returned incomplete state for {unit}"), - )); - } - if values[0] == "not-found" { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::UnitMissing, - format!("systemd unit {unit} was not found"), - )); - } - if values[0] != "loaded" { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::ParseFailed, - format!("unsupported LoadState `{}` for {unit}", values[0]), - )); - } - let active = match values[1] { - "active" => true, - "inactive" | "failed" | "activating" | "deactivating" | "reloading" => false, - v => { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::ParseFailed, - format!("unsupported ActiveState `{v}` for {unit}"), - )); - } - }; - let enabled = match values[2] { - "enabled" | "enabled-runtime" => true, - "disabled" | "static" | "indirect" | "masked" | "generated" | "transient" => false, - v => { - return Err(ProcessManagerError::new( - ProcessManagerErrorKind::ParseFailed, - format!("unsupported UnitFileState `{v}` for {unit}"), - )); - } - }; - Ok(UnitStatus { active, enabled }) - } - async fn verify( - &self, - expected: DetectedStartupMode, - ) -> Result { - let status = self.iota_startup_status().await?; - if status.detected == expected { - Ok(status) - } else { - Err(ProcessManagerError::new( - ProcessManagerErrorKind::VerificationFailed, - format!( - "systemd reported {:?} after applying {:?}", - status.detected, expected - ), - )) - } - } - } - #[async_trait] - impl ProcessManager for SystemdManager { - fn name(&self) -> &'static str { - "systemd" - } - async fn unit_status(&self, unit: &str) -> Result { - self.status(unit).await - } - async fn iota_startup_status(&self) -> Result { - Ok(DaemonStartupStatus::classify( - self.status(self.service).await?, - self.status(self.socket).await?, - )) - } - async fn set_iota_startup_mode( - &self, - mode: StartupMode, - ) -> Result { - match mode { - StartupMode::AlwaysOn => { - self.run(&["disable", self.socket]).await?; - self.run(&["enable", "--now", self.service]).await?; - self.verify(DetectedStartupMode::AlwaysOn).await - } - StartupMode::SocketActivated => { - self.run(&["disable", "--now", self.service]).await?; - self.run(&["enable", "--now", self.socket]).await?; - self.verify(DetectedStartupMode::SocketActivated).await - } - } - } - async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> { - self.run(action).await - } - async fn process_action( - &self, - action: ProcessAction, - ) -> Result { - let verb = match action { - ProcessAction::Start => "start", - ProcessAction::Stop => "stop", - ProcessAction::Restart => "restart", - }; - self.run(&[verb, self.service]).await?; - self.iota_startup_status().await - } - async fn disable_startup(&self) -> Result { - self.run(&["disable", "--now", self.service]).await?; - self.run(&["disable", "--now", self.socket]).await?; - self.verify(DetectedStartupMode::Disabled).await - } - } - - #[cfg(test)] - mod tests { - use super::*; - use std::sync::Mutex; - - struct Fake { - calls: Mutex>>, - results: Mutex>, - } - #[async_trait] - impl CommandExecutor for Fake { - async fn output( - &self, - _: &str, - args: &[&str], - ) -> Result { - self.calls - .lock() - .unwrap() - .push(args.iter().map(|arg| (*arg).to_owned()).collect()); - Ok(self.results.lock().unwrap().remove(0)) - } - } - fn ok(stdout: &str) -> CommandOutput { - CommandOutput { - success: true, - stdout: stdout.into(), - stderr: String::new(), - } - } - - #[tokio::test] - async fn every_systemctl_operation_disables_interactive_features() { - let fake = Arc::new(Fake { - calls: Mutex::new(Vec::new()), - results: Mutex::new(vec![ok("loaded\nactive\nenabled\n")]), - }); - let manager = SystemdManager::with_executor(fake.clone()); - manager.unit_status(SERVICE).await.unwrap(); - let call = &fake.calls.lock().unwrap()[0]; - assert!(call.contains(&"--no-pager".into())); - assert!(call.contains(&"--no-ask-password".into())); - } - - #[tokio::test] - async fn timed_out_real_child_is_terminated_and_reaped() { - use std::fs; - use std::time::Duration; - - let directory = tempfile::tempdir().unwrap(); - let pid_file = directory.path().join("child.pid"); - let script = format!( - "printf '%s' \"$$\" > '{}'; exec sleep 60", - pid_file.display() - ); - let executor = RealExecutor; - let task = tokio::spawn(async move { - executor - .output_with_timeout("sh", &["-c", &script], Duration::from_millis(50)) - .await - }); - - let deadline = tokio::time::Instant::now() + Duration::from_secs(2); - let pid = loop { - if let Ok(contents) = fs::read_to_string(&pid_file) { - if let Ok(pid) = contents.parse::() { - break pid; - } - } - assert!(tokio::time::Instant::now() < deadline); - tokio::task::yield_now().await; - }; - - let result = task.await.unwrap(); - assert_eq!( - result.unwrap_err().kind(), - ProcessManagerErrorKind::TimedOut - ); - assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists()); - - let mut status = 0; - let wait_result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; - assert_eq!(wait_result, -1); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::ECHILD) - ); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn modes_distinct() { - assert_ne!(StartupMode::AlwaysOn, StartupMode::SocketActivated); - } - - struct BlockingExecutor; - #[async_trait::async_trait] - impl CommandExecutor for BlockingExecutor { - async fn output(&self, _: &str, _: &[&str]) -> Result { - std::future::pending().await - } - } - - #[tokio::test] - async fn executor_future_can_be_cancelled_without_blocking_runtime() { - let result = tokio::time::timeout( - std::time::Duration::from_millis(20), - BlockingExecutor.output("systemctl", &["show"]), - ) - .await; - assert!(result.is_err()); - } -} diff --git a/iota-state/Cargo.toml b/iota-state/Cargo.toml deleted file mode 100644 index 4db4406..0000000 --- a/iota-state/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "iota-state" -version = "0.1.0" -edition = "2024" - -[features] -default = ["legacy-globals"] -legacy-globals = [] - -[dependencies] -dashmap = "6.1.0" -once_cell = "1.21.3" -tokio = { version = "1.50.0", features = ["full"] } -json = "*" -sysinfo = "0.38.0" diff --git a/iota-state/src/lib.rs b/iota-state/src/lib.rs deleted file mode 100644 index 8f668ea..0000000 --- a/iota-state/src/lib.rs +++ /dev/null @@ -1,308 +0,0 @@ -use dashmap::DashSet; -use json::{JsonValue, object}; -#[cfg(feature = "legacy-globals")] -use once_cell::sync::Lazy; -use std::collections::VecDeque; -#[cfg(feature = "legacy-globals")] -use std::sync::LazyLock; -use std::sync::{Arc, Mutex, atomic::AtomicBool}; -#[cfg(feature = "legacy-globals")] -use std::thread; -#[cfg(feature = "legacy-globals")] -use std::time::Duration; -#[cfg(feature = "legacy-globals")] -use sysinfo::{RefreshKind, System}; -use tokio::sync::{Mutex as TokioMutex, RwLock}; - -/* Process-owned daemon state and TUI-local state must be separate because IPC, - * rather than shared memory, is the boundary between the two binaries. */ -#[derive(Clone)] -pub struct DaemonState { - pub app: Arc>, - pub shutdown: Arc>, - pub reload: Arc>, - pub active_tasks: Arc>, -} - -impl DaemonState { - pub fn new() -> Self { - Self { - app: Arc::new(Mutex::new(AppState::new())), - shutdown: Arc::new(RwLock::new(false)), - reload: Arc::new(RwLock::new(false)), - active_tasks: Arc::new(DashSet::new()), - } - } -} - -impl Default for DaemonState { - fn default() -> Self { - Self::new() - } -} - -/* The TUI keeps only the daemon data it renders. This state is never shared - * with the daemon and is populated from daemon IPC messages. */ -#[derive(Clone)] -pub struct ClientState { - pub app: Arc>, -} - -impl ClientState { - pub fn new() -> Self { - Self { - app: Arc::new(TokioMutex::new(AppState::new())), - } - } -} - -impl Default for ClientState { - fn default() -> Self { - Self::new() - } -} - -pub const MAX_POINTS: usize = 1000; -pub const MAX_LOGS: usize = 100; - -pub static UNIQUE: AtomicBool = AtomicBool::new(true); - -#[derive(Clone, Debug)] -pub struct UiLogEntry { - pub timestamp_ms: u128, - pub sender: String, - pub message: String, - pub is_error: bool, -} - -impl UiLogEntry { - pub fn format_timestamp(&self) -> String { - let secs = (self.timestamp_ms / 1000) as i64; - let hours = (secs / 3600) % 24; - let minutes = (secs / 60) % 60; - let seconds = secs % 60; - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) - } -} - -#[derive(Clone)] -pub struct AppState { - pub logs: VecDeque, - pub cpu: Vec<(f64, f64)>, - pub ram: Vec<(f64, f64)>, - pub ping: Vec<(f64, f64)>, - pub net_up: Vec<(f64, f64)>, - pub net_down: Vec<(f64, f64)>, - pub sys_info: String, - next_sample_id: u64, -} - -impl AppState { - pub fn new() -> Self { - Self { - logs: VecDeque::new(), - cpu: Vec::new(), - ram: Vec::new(), - ping: Vec::new(), - net_up: Vec::new(), - net_down: Vec::new(), - sys_info: String::from("Loading..."), - next_sample_id: 0, - } - } - - pub fn push_log(&mut self, msg: UiLogEntry) { - if self.logs.len() >= MAX_LOGS { - self.logs.pop_front(); - } - self.logs.push_back(msg); - } - - pub fn get_logs(&self) -> &VecDeque { - &self.logs - } - - pub fn push_cpu(&mut self, pt: (f64, f64)) { - let x = self.next_sample(); - self.cpu.push((x, pt.1)); - if self.cpu.len() > MAX_POINTS { - self.cpu.remove(0); - } - } - - pub fn push_ram(&mut self, pt: (f64, f64)) { - let x = self.next_sample(); - self.ram.push((x, pt.1)); - if self.ram.len() > MAX_POINTS { - self.ram.remove(0); - } - } - - pub fn push_ping_val(&mut self, pt: f64) { - let x = self.next_sample(); - self.ping.push((x, pt)); - if self.ping.len() > MAX_POINTS { - self.ping.remove(0); - } - } - - pub fn push_net_up(&mut self, pt: (f64, f64)) { - let x = self.next_sample(); - self.net_up.push((x, pt.1)); - if self.net_up.len() > MAX_POINTS { - self.net_up.remove(0); - } - } - - pub fn push_net_down(&mut self, pt: (f64, f64)) { - let x = self.next_sample(); - self.net_down.push((x, pt.1)); - if self.net_down.len() > MAX_POINTS { - self.net_down.remove(0); - } - } - - fn next_sample(&mut self) -> f64 { - let value = self.next_sample_id as f64; - self.next_sample_id = self.next_sample_id.saturating_add(1); - value - } - - pub fn to_json(&self) -> JsonValue { - object! { - "cpu" => self.cpu.iter().map(|(_, y)| *y).collect::>(), - "ram" => self.ram.iter().map(|(_, y)| *y).collect::>(), - "ping" => self.ping.iter().map(|(_, y)| *y).collect::>(), - "net_up" => self.net_up.iter().map(|(_, y)| *y).collect::>(), - "net_down" => self.net_down.iter().map(|(_, y)| *y).collect::>(), - } - } - - pub fn with_width(&self, width: u16) -> Self { - let mut new = self.clone(); - new.cpu = Self::downsample_to_fit_width(&new.cpu, width); - new.ram = Self::downsample_to_fit_width(&new.ram, width); - new.ping = Self::downsample_to_fit_width(&new.ping, width); - new.net_up = Self::downsample_to_fit_width(&new.net_up, width); - new.net_down = Self::downsample_to_fit_width(&new.net_down, width); - new - } - - fn downsample_to_fit_width(data: &[(f64, f64)], width: u16) -> Vec<(f64, f64)> { - let width_usize = (width as usize) * 2; - let len = data.len(); - - if len >= width_usize { - data[len - width_usize..].to_vec() - } else { - let mut result = Vec::with_capacity(width_usize); - - let dx = 1.0; - let pad_len = width_usize - len; - - let start_x = data - .first() - .map(|(x, _)| x - (dx * pad_len as f64)) - .unwrap_or(0.0); - - for i in 0..pad_len { - result.push((start_x + i as f64 * dx, -1.0)); - } - - result.extend_from_slice(data); - result - } - } -} - -#[cfg(feature = "legacy-globals")] -#[deprecated(note = "use DaemonState or ClientState")] -pub static APP_STATE: LazyLock>> = - LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); - -#[cfg(feature = "legacy-globals")] -#[deprecated(note = "use DaemonState")] -pub static SHUTDOWN: Lazy> = Lazy::new(|| RwLock::new(false)); -#[cfg(feature = "legacy-globals")] -#[deprecated(note = "use DaemonState")] -pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); -#[cfg(feature = "legacy-globals")] -#[deprecated(note = "use DaemonState")] -pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); - -#[cfg(feature = "legacy-globals")] -pub fn setup(state: &DaemonState) { - state.active_tasks.insert("System info loader".to_string()); - let state = state.clone(); - tokio::spawn(async move { - let mut sys = System::new_with_specifics(RefreshKind::everything()); - let mut last_total_received = 0u64; - let mut last_total_transmitted = 0u64; - let mut counter = 0.0; - loop { - if *state.shutdown.read().await { - break; - } - sys.refresh_all(); - - let mut tcpu = 0; - for cpu in sys.cpus() { - tcpu += cpu.cpu_usage() as i64; - tcpu /= 2; - } - let ram = (sys.used_memory() as f64 / sys.total_memory() as f64) * 100.0; - - let total_received = 0u64; - let total_transmitted = 0u64; - - let delta_received = if last_total_received == 0 { - 0 - } else { - total_received.saturating_sub(last_total_received) - }; - let delta_transmitted = if last_total_transmitted == 0 { - 0 - } else { - total_transmitted.saturating_sub(last_total_transmitted) - }; - last_total_received = total_received; - last_total_transmitted = total_transmitted; - - let net_down = delta_received as f64; - let net_up = delta_transmitted as f64; - - { - let mut st = state.app.lock().unwrap(); - st.push_cpu((counter, tcpu as f64)); - st.push_ram((counter, ram)); - st.push_net_down((counter, net_down)); - st.push_net_up((counter, net_up)); - - st.sys_info = format!("NetDown: {} NetUp: {}", delta_received, delta_transmitted); - } - - counter += 1.0; - if counter > 30.0 { - thread::sleep(Duration::from_millis(500)); - } else { - thread::sleep(Duration::from_millis(5)); - } - } - state.active_tasks.remove("System info loader"); - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn metric_coordinates_remain_monotonic_after_history_rollover() { - let mut state = AppState::new(); - for value in 0..(MAX_POINTS + 25) { - state.push_ping_val(value as f64); - } - assert_eq!(state.ping.len(), MAX_POINTS); - assert!(state.ping.windows(2).all(|pair| pair[0].0 < pair[1].0)); - } -} diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml deleted file mode 100644 index 9f91c52..0000000 --- a/iota-storage/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "iota-storage" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-logger = { path = "../iota-logger" } -iota-util = { path = "../iota-util" } -iota-paths = { path = "../iota-paths" } -base64 = "0.22.1" -json = "*" -arc-swap = "1" -once_cell = "1.21.3" -r2d2 = "0.8" -serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" -thiserror = "2" -rand = "0.8" -rusqlite = "0.40.0" -tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs deleted file mode 100644 index a0e5061..0000000 --- a/iota-storage/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod storage_error; -pub mod users; -pub mod util; diff --git a/iota-storage/src/storage_error.rs b/iota-storage/src/storage_error.rs deleted file mode 100644 index f407b7e..0000000 --- a/iota-storage/src/storage_error.rs +++ /dev/null @@ -1,19 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum StorageError { - #[error("Database error: {0}")] - Db(#[from] rusqlite::Error), - #[error("Connection pool error: {0}")] - Pool(String), - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("message has reached the unique reaction limit")] - ReactionLimitReached, - #[error("revision conflict")] - RevisionConflict, - #[error("pending relay ownership is unknown")] - PendingRelayOwnershipUnknown, - #[error("{0}")] - Other(String), -} diff --git a/iota-storage/src/users/contact.rs b/iota-storage/src/users/contact.rs deleted file mode 100644 index 4d2ca43..0000000 --- a/iota-storage/src/users/contact.rs +++ /dev/null @@ -1,36 +0,0 @@ -#[derive(Debug, Clone)] -pub struct Contact { - pub user_id: i64, - pub user_name: Option, - pub created_at: i64, - pub last_message_at: Option, -} - -impl Default for Contact { - fn default() -> Self { - Contact { - user_id: 0, - user_name: None, - created_at: 0, - last_message_at: None, - } - } -} - -impl Contact { - pub fn new(user_id: i64) -> Self { - let created_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - Contact { - user_id: user_id, - user_name: None, - created_at, - last_message_at: None, - } - } - pub fn set_last_message_at(&mut self, p0: i64) { - self.last_message_at = Option::from(p0); - } -} diff --git a/iota-storage/src/users/pending_operations.rs b/iota-storage/src/users/pending_operations.rs deleted file mode 100644 index 4993f17..0000000 --- a/iota-storage/src/users/pending_operations.rs +++ /dev/null @@ -1,173 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use rusqlite::params; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PendingUserOperationKind { - Create, - Attach, - Release, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PendingUserOperationPhase { - Prepared, - CredentialWritten, - RemoteCommitted, - LocalCommitted, -} - -impl PendingUserOperationPhase { - fn as_str(self) -> &'static str { - match self { - Self::Prepared => "prepared", - Self::CredentialWritten => "credential_written", - Self::RemoteCommitted => "remote_committed", - Self::LocalCommitted => "local_committed", - } - } - - fn parse(value: &str) -> Result { - match value { - "prepared" => Ok(Self::Prepared), - "credential_written" => Ok(Self::CredentialWritten), - "remote_committed" => Ok(Self::RemoteCommitted), - "local_committed" => Ok(Self::LocalCommitted), - _ => Err(StorageError::Other( - "unknown pending user operation phase".into(), - )), - } - } -} - -impl PendingUserOperationKind { - fn as_str(self) -> &'static str { - match self { - Self::Create => "create", - Self::Attach => "attach", - Self::Release => "release", - } - } - - fn parse(value: &str) -> Result { - match value { - "create" => Ok(Self::Create), - "attach" => Ok(Self::Attach), - "release" => Ok(Self::Release), - _ => Err(StorageError::Other("unknown pending user operation".into())), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingUserOperation { - pub user_id: i64, - pub operation: PendingUserOperationKind, - pub username: String, - pub public_key: Option, - pub private_key_hash: Option, - pub reset_token: Option, - pub registration_token: Option, - pub phase: PendingUserOperationPhase, - pub created_at: i64, -} - -pub fn upsert(operation: &PendingUserOperation) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - r#" - INSERT INTO pending_user_operations ( - user_id, operation, username, public_key, private_key_hash, - reset_token, registration_token, phase, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(user_id) DO UPDATE SET - operation = excluded.operation, - username = excluded.username, - public_key = excluded.public_key, - private_key_hash = excluded.private_key_hash, - reset_token = excluded.reset_token, - registration_token = excluded.registration_token, - phase = excluded.phase, - created_at = excluded.created_at - "#, - params![ - operation.user_id, - operation.operation.as_str(), - operation.username, - operation.public_key, - operation.private_key_hash, - operation.reset_token, - operation.registration_token, - operation.phase.as_str(), - operation.created_at, - ], - )?; - Ok(()) - }) -} - -pub fn get_all() -> Result, StorageError> { - db::with_db(|conn| { - let mut statement = conn.prepare( - "SELECT user_id, operation, username, public_key, private_key_hash, reset_token, registration_token, phase, created_at FROM pending_user_operations ORDER BY created_at", - )?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, String>(7)?, - row.get::<_, i64>(8)?, - )) - })?; - rows.map(|row| { - let ( - user_id, - operation, - username, - public_key, - private_key_hash, - reset_token, - registration_token, - phase, - created_at, - ) = row?; - Ok(PendingUserOperation { - user_id, - operation: PendingUserOperationKind::parse(&operation)?, - username, - public_key, - private_key_hash, - reset_token, - registration_token, - phase: PendingUserOperationPhase::parse(&phase)?, - created_at, - }) - }) - .collect() - }) -} - -pub fn update_phase(user_id: i64, phase: PendingUserOperationPhase) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - "UPDATE pending_user_operations SET phase = ?1 WHERE user_id = ?2", - params![phase.as_str(), user_id], - )?; - Ok(()) - }) -} - -pub fn remove(user_id: i64) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - "DELETE FROM pending_user_operations WHERE user_id = ?1", - [user_id], - )?; - Ok(()) - }) -} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs deleted file mode 100644 index 43b2ed4..0000000 --- a/iota-storage/src/users/user_manager.rs +++ /dev/null @@ -1,437 +0,0 @@ -use crate::users::user_profile::UserProfile; -use crate::util::db; -use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; -use rusqlite::params; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LocalUserState { - Managed, - Released, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct UserResidency { - pub user_id: i64, - pub username: String, - pub state: LocalUserState, - pub data_present: bool, -} - -fn now_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -pub fn add_user(user: UserProfile) { - if let Err(e) = try_add_user(user) { - eprintln!("Failed to add_user: {}", e); - } -} - -pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - r#" - INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(user_id) DO UPDATE SET - username = excluded.username, - public_key = excluded.public_key, - private_key_hash = excluded.private_key_hash, - reset_token = excluded.reset_token, - display_name = excluded.display_name - "#, - params![ - user.user_id, - user.username, - user.public_key, - user.private_key_hash, - user.reset_token, - user.created_at, - user.display_name, - ], - )?; - - for (app_id, app_secret) in &user.trusted_apps { - tx.execute( - r#" - INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) - VALUES (?1, ?2, ?3) - "#, - params![user.user_id, app_id, app_secret], - )?; - } - tx.execute( - r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) - VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) - ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#, - params![user.user_id, user.username, now_millis()], - )?; - Ok(()) - }) -} - -pub fn update_user(user: UserProfile) { - add_user(user); -} - -pub fn get_user_by_username(username: &str) -> Option { - match db::with_db(|conn| { - match conn.query_row( - "SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name FROM users WHERE username = ?1 LIMIT 1", - params![username], - |r| { - let user_id: i64 = r.get(0)?; - Ok(UserProfile { - user_id, - username: r.get(1)?, - display_name: r.get(6)?, - public_key: r.get(2)?, - private_key_hash: r.get(3)?, - created_at: r.get(5)?, - reset_token: r.get(4)?, - trusted_apps: std::collections::HashMap::new(), - }) - }, - ) { - Ok(user) => Ok(Some(user)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - }) { - Ok(opt) => opt, - Err(e) => { - eprintln!("Error querying user by username: {}", e); - None - } - } -} - -pub fn get_user(user_id: i64) -> Result, crate::storage_error::StorageError> { - let user = db::with_db(|conn| { - match conn.query_row( - "SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name FROM users WHERE user_id = ?1 LIMIT 1", - params![user_id], - |r| { - let user_id: i64 = r.get(0)?; - Ok(UserProfile { - user_id, - username: r.get(1)?, - display_name: r.get(6)?, - public_key: r.get(2)?, - private_key_hash: r.get(3)?, - created_at: r.get(5)?, - reset_token: r.get(4)?, - trusted_apps: std::collections::HashMap::new(), - }) - }, - ) { - Ok(user) => Ok(Some(user)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - })?; - user.map(|mut user| { - user.trusted_apps = load_trusted_apps(user_id)?; - Ok(user) - }) - .transpose() -} - -pub fn get_users() -> Vec { - match db::with_db(|conn| { - let mut stmt = conn.prepare( - r#" - SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name - FROM users - ORDER BY username - "#, - )?; - - let rows = stmt.query_map([], |r| { - let user_id: i64 = r.get(0)?; - let username: String = r.get(1)?; - let public_key: String = r.get(2)?; - let private_key_hash: String = r.get(3)?; - let reset_token: String = r.get(4)?; - let created_at: i64 = r.get(5)?; - let display_name: Option = r.get(6)?; - - Ok(UserProfile { - user_id, - username, - display_name, - public_key, - private_key_hash, - created_at, - reset_token, - trusted_apps: std::collections::HashMap::new(), - }) - })?; - - let mut out = Vec::new(); - for row in rows { - match row { - Ok(mut user) => { - user.trusted_apps = load_trusted_apps(user.user_id)?; - out.push(user); - } - Err(e) => eprintln!("Failed to read user row: {}", e), - } - } - Ok(out) - }) { - Ok(v) => v, - Err(e) => { - eprintln!("Failed to query users: {}", e); - Vec::new() - } - } -} - -fn load_trusted_apps( - user_id: i64, -) -> Result, crate::storage_error::StorageError> { - db::with_db(|conn| { - let mut stmt = - conn.prepare("SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1")?; - let rows = stmt.query_map(params![user_id], |r| { - Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) - })?; - - let mut map = std::collections::HashMap::new(); - for row in rows { - let (key, value) = row?; - map.insert(key, value); - } - Ok(map) - }) -} - -pub fn remove_user(user_id: i64) { - if let Err(e) = db::with_db(|conn| { - conn.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; - conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; - Ok(()) - }) { - eprintln!("Failed to remove_user: {}", e); - } -} - -/// Remove only local management authority. Hosted content is intentionally -/// retained and is indexed as released for a later purge operation. -pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { - let username = get_user(user_id)? - .map(|user| user.username) - .ok_or_else(|| { - crate::storage_error::StorageError::Other("managed user was not found".into()) - })?; - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; - tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; - tx.execute( - r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) - VALUES (?1, ?2, 'released', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3) - ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'released', updated_at = excluded.updated_at"#, - params![user_id, username, now_millis()], - )?; - tx.commit()?; - Ok(()) - })?; - remove_user_credential(user_id, Some(&username)) - .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) -} - -/// Authoritative hosted-data erasure used by local purge and future Omega -/// erasure delivery. Management metadata and credentials are left intact. -pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> { - if crate::util::relay_queue::has_unclassified_relays()? { - return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown); - } - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( - "DELETE FROM message_receipts WHERE storage_owner = ?1", - params![user_id], - )?; - tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; - tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; - tx.execute( - "DELETE FROM messages WHERE storage_owner = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM contacts WHERE storage_owner = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM communities WHERE storage_owner = ?1", - params![user_id], - )?; - tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; - tx.execute( - "DELETE FROM synced_settings WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM user_blobs WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM blocked_users WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM user_receipt_policy WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM user_message_storage_policy WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM sync_events WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM sync_heads WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM client_sync_state WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM client_message_deliveries WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1", - params![user_id], - )?; - tx.execute( - "DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_id = ?1 OR relay_inbox.destination_id = ?1))", - params![user_id], - )?; - tx.execute( - "DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1", - params![user_id], - )?; - tx.execute( - "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", - params![user_id, now_millis()], - )?; - tx.commit()?; - Ok(()) - })?; - crate::util::e2ee_storage::purge_user(user_id) - .map_err(crate::storage_error::StorageError::Other)?; - delete_user_directory(user_id) - .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) -} - -/// Complete local erasure is idempotent and is the target for a durable -/// Omega-hosted erasure request after account deletion. -pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { - let username = get_user(user_id)?.map(|user| user.username).or_else(|| { - get_residency() - .into_iter() - .find(|entry| entry.user_id == user_id) - .map(|entry| entry.username) - }); - purge_user_data(user_id)?; - db::with_db(|conn| { - conn.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; - conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; - conn.execute( - "DELETE FROM user_residency WHERE user_id = ?1", - params![user_id], - )?; - Ok(()) - })?; - remove_user_credential(user_id, username.as_deref()) - .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) -} - -pub fn get_residency() -> Vec { - db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?; - let rows = stmt.query_map([], |row| { - let lifecycle: String = row.get(2)?; - Ok(UserResidency { - user_id: row.get(0)?, username: row.get(1)?, - state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released }, - data_present: row.get::<_, String>(3)? == "present", - }) - })?; - rows.collect::, _>>().map_err(Into::into) - }).unwrap_or_default() -} - -pub fn clear() { - if let Err(e) = db::with_db(|conn| { - conn.execute_batch( - "DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;", - )?; - Ok(()) - }) { - eprintln!("Failed to clear users: {}", e); - } -} - -pub fn save_users() { - // No-op: users are auto-saved via SQLite. -} - -pub fn load_users_sync() -> std::io::Result<()> { - // Users are loaded from SQLite on demand. This function is kept for API compat. - // If we need to migrate from a legacy users.json file, we can do so here. - let content = load_file("", "users.json"); - if content.trim().is_empty() { - return Ok(()); - } - if let Ok(parsed) = json::parse(&content) { - if let json::JsonValue::Array(arr) = parsed { - for j in arr.iter() { - if let Some(up) = UserProfile::from_json(j) { - add_user(up); - } - } - } - } - // Rename the old file so we don't re-import - let _ = std::fs::rename( - std::path::PathBuf::from(iota_util::file_util::get_directory()).join("users.json"), - std::path::PathBuf::from(iota_util::file_util::get_directory()).join("users.json.imported"), - ); - Ok(()) -} - -pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { - let path = format!("users/{}/apps", user_id); - let name = format!("{}.json", app_identifier); - save_file(&path, &name, data); -} - -pub fn load_app_data(user_id: i64, app_identifier: &str) -> String { - let path = format!("users/{}/apps", user_id); - let name = format!("{}.json", app_identifier); - load_file(&path, &name) -} diff --git a/iota-storage/src/util/blocked_users.rs b/iota-storage/src/util/blocked_users.rs deleted file mode 100644 index 7e22634..0000000 --- a/iota-storage/src/util/blocked_users.rs +++ /dev/null @@ -1,104 +0,0 @@ -/* Blocks are user policy independent of contacts or conversations. */ -use crate::storage_error::StorageError; -use crate::util::{db, sync}; -use rusqlite::{OptionalExtension, params}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BlockMutation { - pub blocked_user_id: i64, - pub revision: i64, - pub deleted: bool, -} -fn validate(user_id: i64, blocked_user_id: i64) -> Result<(), StorageError> { - if user_id <= 0 || blocked_user_id <= 0 || user_id == blocked_user_id { - Err(StorageError::Other("invalid block relationship".into())) - } else { - Ok(()) - } -} -pub fn block(user_id: i64, blocked_user_id: i64) -> Result { - validate(user_id, blocked_user_id)?; - db::with_immediate_transaction(|tx| { - let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", params![user_id, blocked_user_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?; - if let Some((_, revision)) = prior { - return Ok(BlockMutation { - blocked_user_id, - revision, - deleted: false, - }); - } - let created_at = sync::now_millis(); - tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, revision, created_at) VALUES (?1, ?2, 0, ?3)", params![user_id, blocked_user_id, created_at])?; - let id = tx.last_insert_rowid(); - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::BlockedUser, - id, - sync::Operation::Upsert, - )?; - tx.execute( - "UPDATE blocked_users SET revision = ?2 WHERE id = ?1", - params![id, revision], - )?; - Ok(BlockMutation { - blocked_user_id, - revision, - deleted: false, - }) - }) -} -pub fn unblock(user_id: i64, blocked_user_id: i64) -> Result, StorageError> { - validate(user_id, blocked_user_id)?; - db::with_immediate_transaction(|tx| { - let id = tx - .query_row( - "SELECT id FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", - params![user_id, blocked_user_id], - |r| r.get(0), - ) - .optional()?; - let Some(id) = id else { - return Ok(None); - }; - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::BlockedUser, - id, - sync::Operation::Delete, - )?; - tx.execute("DELETE FROM blocked_users WHERE id = ?1", [id])?; - Ok(Some(BlockMutation { - blocked_user_id, - revision, - deleted: true, - })) - }) -} -pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result { - validate(user_id, candidate_sender_id)?; - db::with_db(|conn| { - Ok(conn - .query_row( - "SELECT 1 FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", - params![user_id, candidate_sender_id], - |_| Ok(()), - ) - .optional()? - .is_some()) - }) -} -pub fn list(user_id: i64) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid block owner".into())); - } - db::with_db(|conn| { - let mut stmt = conn.prepare( - "SELECT blocked_user_id FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_user_id", - )?; - stmt.query_map([user_id], |r| r.get(0))? - .collect::, _>>() - .map_err(StorageError::from) - }) -} diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs deleted file mode 100644 index e04406a..0000000 --- a/iota-storage/src/util/chat_files.rs +++ /dev/null @@ -1,1234 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use crate::util::message_storage_policy::{self, MessageRetention}; -use crate::util::sync::{self, EntityType, Operation}; -use iota_logger::log; -use rusqlite::{OptionalExtension, Transaction, params}; - -pub const MAX_UNIQUE_REACTIONS_PER_MESSAGE: usize = 10; - -#[derive(PartialEq, Debug, Clone)] -pub enum MessageState { - Read, - Received, - Sent, - Sending, -} - -impl MessageState { - pub fn as_str(&self) -> &'static str { - match self { - MessageState::Read => "read", - MessageState::Received => "received", - MessageState::Sent => "sent", - MessageState::Sending => "sending", - } - } - - pub fn from_str(value: &str) -> Self { - match value.to_lowercase().as_str() { - "read" => MessageState::Read, - "received" => MessageState::Received, - "sent" => MessageState::Sent, - _ => MessageState::Sending, - } - } - - pub fn upgrade(self, other: Self) -> Self { - if other == Self::Read || self == Self::Read { - Self::Read - } else if other == Self::Received || self == Self::Received { - Self::Received - } else if other == Self::Sent || self == Self::Sent { - Self::Sent - } else { - Self::Sending - } - } -} - -#[derive(Debug, Clone)] -pub struct StoredMessage { - pub id: i64, - pub external_user: i64, - pub relay_signer_id: Option, - pub relay_message_id: Option, - pub message_time: i64, - pub authored_at: Option, - pub origin_iota_received_at: Option, - pub destination_iota_received_at: Option, - pub client_received_at: Option, - pub client_received_recorded_at: Option, - pub read_at: Option, - pub read_recorded_at: Option, - pub delivery_failed_at: Option, - pub delivery_failure: Option, - pub content: String, - pub edited: bool, - pub sent_by_self: bool, - pub message_state: String, - pub height: i64, - pub reply_to: Option, - pub reactions: Vec, -} - -pub struct NewMessage<'a> { - pub relay_signer_id: i64, - pub relay_message_id: &'a str, - pub authored_at: i64, - pub send_time: i64, - pub storage_owner: i64, - pub external_user: i64, - pub sent_by_self: bool, - pub content: &'a str, - pub height: i64, - pub reply_to: Option, - pub origin_iota_received_at: Option, - pub destination_iota_received_at: Option, - pub initial_state: MessageState, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredReaction { - pub reaction: String, - pub user_id: i64, -} - -/* - * Each edit is recorded in message_edits with the before/after content and a - * timestamp. Only the original sender (sent_by_self = 1) may edit. - */ -pub fn edit_message( - storage_owner: i64, - external_user: i64, - message_time: i64, - editor_id: i64, - new_content: &str, -) -> Result<(), StorageError> { - update_message_content( - storage_owner, - external_user, - message_time, - editor_id, - new_content, - true, - ) -} - -/* Applies an edit received from the message sender to the recipient's copy. */ -pub fn apply_remote_edit( - storage_owner: i64, - external_user: i64, - message_time: i64, - editor_id: i64, - new_content: &str, -) -> Result<(), StorageError> { - if editor_id != external_user { - return Err(StorageError::Other( - "Remote editor does not match chat partner".into(), - )); - } - - update_message_content( - storage_owner, - external_user, - message_time, - editor_id, - new_content, - false, - ) -} - -fn update_message_content( - storage_owner: i64, - external_user: i64, - message_time: i64, - editor_id: i64, - new_content: &str, - require_sent_by_self: bool, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let msg = conn.query_row( - r#" - SELECT id, content, sent_by_self, history_deleted - FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, i64>(3)?, - )) - }, - )?; - - let (msg_id, old_content, sent_by_self, history_deleted) = msg; - if require_sent_by_self && sent_by_self != 1 { - return Err(StorageError::Other( - "Only the original sender can edit this message".into(), - )); - } - if !require_sent_by_self && sent_by_self != 0 { - return Err(StorageError::Other( - "Remote edits may only update received messages".into(), - )); - } - - if history_deleted != 0 { - return Ok(()); - } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - let tx = conn.unchecked_transaction()?; - tx.execute( - r#" - INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) - VALUES (?1, ?2, ?3, ?4, ?5) - "#, - params![msg_id, old_content, new_content, now, editor_id], - )?; - - tx.execute( - r#" - UPDATE messages - SET content = ?1, edited_count = edited_count + 1 - WHERE id = ?2 - "#, - params![new_content, msg_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; - - Ok(()) - }) -} - -pub fn hard_delete_message( - storage_owner: i64, - external_user: i64, - message_time: i64, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let msg_id = conn - .query_row( - r#" - SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| row.get::<_, i64>(0), - ) - .optional()?; - let Some(msg_id) = msg_id else { - return Ok(()); - }; - - let tx = conn.unchecked_transaction()?; - purge_message_in_tx(&tx, storage_owner, msg_id)?; - tx.commit()?; - Ok(()) - }) -} - -pub fn purge_message(storage_owner: i64, message_id: i64) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| purge_message_in_tx(tx, storage_owner, message_id)) -} - -pub fn remove_message_history(storage_owner: i64, message_id: i64) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| remove_message_history_in_tx(tx, storage_owner, message_id)) -} - -/* Resolves the protocol identity retained in a tombstone before removing visible history. */ -pub fn remove_message_history_by_relay_identity( - storage_owner: i64, - relay_signer_id: i64, - relay_message_id: &str, -) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - let message_id = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], - |row| row.get::<_, i64>(0), - ).optional()?; - if let Some(message_id) = message_id { - remove_message_history_in_tx(tx, storage_owner, message_id)?; - } - Ok(()) - }) -} - -pub fn remove_message_history_in_tx( - tx: &Transaction<'_>, - storage_owner: i64, - message_id: i64, -) -> Result<(), StorageError> { - let message: Option<(i64, i64)> = tx - .query_row( - "SELECT external_user, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2", - params![message_id, storage_owner], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - let Some((external_user, history_deleted)) = message else { - return Ok(()); - }; - if history_deleted != 0 { - return Ok(()); - } - tx.execute( - "DELETE FROM message_edits WHERE message_id = ?1", - [message_id], - )?; - tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?; - tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_id = message_receipts.target_signer_id AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?; - tx.execute("UPDATE messages SET content = '', history_deleted = 1, history_deleted_at = ?2, expires_at = NULL, client_received_at = NULL, client_received_recorded_at = NULL, read_at = NULL, read_recorded_at = NULL WHERE id = ?1", params![message_id, sync::now_millis()])?; - sync::record_event( - tx, - storage_owner, - EntityType::Message, - message_id, - Operation::Delete, - )?; - update_contact_last_message_in_tx(tx, storage_owner, external_user)?; - Ok(()) -} - -fn update_contact_last_message_in_tx( - tx: &Transaction<'_>, - storage_owner: i64, - external_user: i64, -) -> Result<(), StorageError> { - tx.execute("UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND user_id = ?2", params![storage_owner, external_user])?; - Ok(()) -} - -pub fn purge_message_in_tx( - tx: &Transaction<'_>, - storage_owner: i64, - message_id: i64, -) -> Result<(), StorageError> { - let (external_user, relay_signer_id, relay_message_id) = tx.query_row( - "SELECT external_user, relay_signer_id, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2", - params![message_id, storage_owner], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)), - )?; - if let (Some(relay_signer_id), Some(relay_message_id)) = (relay_signer_id, relay_message_id) { - tx.execute( - "DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_id = ?2 AND target_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], - )?; - } - tx.execute( - "DELETE FROM message_edits WHERE message_id = ?1", - [message_id], - )?; - tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?; - tx.execute("DELETE FROM messages WHERE id = ?1", [message_id])?; - sync::record_event( - tx, - storage_owner, - EntityType::Message, - message_id, - Operation::Delete, - )?; - update_contact_last_message_in_tx(tx, storage_owner, external_user)?; - Ok(()) -} - -/* Deletes a message from the sender's local copy after checking ownership. */ -pub fn delete_message( - storage_owner: i64, - external_user: i64, - message_time: i64, -) -> Result<(), StorageError> { - match ensure_message_direction(storage_owner, external_user, message_time, true) { - Ok(()) => hard_delete_message(storage_owner, external_user, message_time), - Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()), - Err(error) => Err(error), - } -} - -/* Flags the recipient's local copy after validating its sender, preserving its history. */ -pub fn apply_remote_delete( - storage_owner: i64, - external_user: i64, - message_time: i64, - sender_id: i64, -) -> Result<(), StorageError> { - if sender_id != external_user { - return Err(StorageError::Other( - "Remote sender does not match chat partner".into(), - )); - } - match ensure_message_direction(storage_owner, external_user, message_time, false) { - Ok(()) => flag_deleted_by_external(storage_owner, external_user, message_time), - Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()), - Err(error) => Err(error), - } -} - -fn ensure_message_direction( - storage_owner: i64, - external_user: i64, - message_time: i64, - expected_sent_by_self: bool, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let sent_by_self: i64 = conn.query_row( - r#" - SELECT sent_by_self FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| row.get(0), - )?; - if (sent_by_self != 0) != expected_sent_by_self { - return Err(StorageError::Other( - "Message sender is not authorized".into(), - )); - } - Ok(()) - }) -} - -/* - * Marks a message as deleted by the external user rather than removing the row, - * so the storage owner still sees a tombstone in the UI. - */ -pub fn flag_deleted_by_external( - storage_owner: i64, - external_user: i64, - message_time: i64, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let affected = tx.execute( - r#" - UPDATE messages - SET deleted_by_external = 1 - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - "#, - params![storage_owner, external_user, message_time], - )?; - if affected == 0 { - return Err(StorageError::Other("Message not found".into())); - } - let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Delete, - )?; - update_contact_last_message_in_tx(&tx, storage_owner, external_user)?; - tx.commit()?; - Ok(()) - }) -} - -/* - * Removes the edit trail but keeps the message with edited_count > 0 so - * the UI still shows the "edited" indicator. Only the own user should - * call this. - */ -pub fn delete_edit_history( - storage_owner: i64, - external_user: i64, - message_time: i64, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let (msg_id, history_deleted): (i64, i64) = conn.query_row( - r#" - SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - if history_deleted != 0 { - return Ok(()); - } - - let tx = conn.unchecked_transaction()?; - tx.execute( - "DELETE FROM message_edits WHERE message_id = ?1", - params![msg_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn add_reaction( - storage_owner: i64, - external_user: i64, - message_time: i64, - user_id: i64, - reaction: &str, -) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - let (msg_id, history_deleted): (i64, i64) = tx.query_row( - r#" - SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - if history_deleted != 0 { - return Ok(()); - } - - let reaction_exists: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM reactions WHERE message_id = ?1 AND reaction = ?2)", - params![msg_id, reaction], - |row| row.get(0), - )?; - if !reaction_exists { - let unique_reactions: i64 = tx.query_row( - "SELECT COUNT(DISTINCT reaction) FROM reactions WHERE message_id = ?1", - [msg_id], - |row| row.get(0), - )?; - if unique_reactions >= MAX_UNIQUE_REACTIONS_PER_MESSAGE as i64 { - return Err(StorageError::ReactionLimitReached); - } - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - let inserted = tx.execute( - r#" - INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) - VALUES (?1, ?2, ?3, ?4) - "#, - params![msg_id, user_id, reaction, now], - )?; - if inserted > 0 { - sync::record_event( - tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - Ok(()) - } else { - Ok(()) - } - }) -} - -pub fn remove_reaction( - storage_owner: i64, - external_user: i64, - message_time: i64, - user_id: i64, - reaction: &str, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let (msg_id, history_deleted): (i64, i64) = conn.query_row( - r#" - SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, message_time], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - if history_deleted != 0 { - return Ok(()); - } - - let tx = conn.unchecked_transaction()?; - tx.execute( - "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3", - params![msg_id, user_id, reaction], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn add_message(message: NewMessage<'_>) -> Result { - let NewMessage { - relay_signer_id, - relay_message_id, - authored_at, - send_time, - storage_owner, - external_user, - sent_by_self, - content, - height, - reply_to, - origin_iota_received_at, - destination_iota_received_at, - initial_state, - } = message; - let stored_at = sync::now_millis(); - let policy = message_storage_policy::get(storage_owner)?; - let expires_at = match policy.retention { - MessageRetention::Forever => None, - MessageRetention::Duration { duration_ms } => Some( - stored_at - .checked_add(duration_ms) - .ok_or_else(|| StorageError::Other("message expiry overflow".into()))?, - ), - }; - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( - r#" - INSERT INTO messages ( - storage_owner, external_user, message_time, content, sent_by_self, - message_state, height, reply_to, relay_signer_id, relay_message_id, - authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, expires_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) - "#, - params![ - storage_owner, - external_user, - send_time, - content, - i64::from(sent_by_self), - initial_state.as_str(), - height, - reply_to, - relay_signer_id, - relay_message_id, - authored_at, - origin_iota_received_at, - destination_iota_received_at, - stored_at, - expires_at, - ], - )?; - let msg_id = tx.last_insert_rowid(); - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - let mut contact = crate::users::contact::Contact::new(external_user); - contact.set_last_message_at( - destination_iota_received_at - .or(origin_iota_received_at) - .unwrap_or(authored_at), - ); - crate::util::chats_util::upsert_contact(&tx, storage_owner, &contact)?; - tx.commit()?; - Ok(msg_id) - }) -} - -pub fn change_message_state_by_relay_id( - storage_owner: i64, - relay_signer_id: i64, - relay_message_id: &str, - new_state: MessageState, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let Some((msg_id, current)) = tx - .query_row( - "SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) - .optional()? - else { - return Ok(()); - }; - let state = MessageState::from_str(¤t).upgrade(new_state).as_str(); - tx.execute( - "UPDATE messages SET message_state = ?1 WHERE id = ?2", - params![state, msg_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn record_message_receipt( - storage_owner: i64, - target_signer_id: i64, - target_message_id: &str, - receipt_signer_id: i64, - receipt_message_id: &str, - receipt_type: MessageState, - event_at: i64, - recorded_at: i64, -) -> Result<(), StorageError> { - let receipt_type = match receipt_type { - MessageState::Received => "received", - MessageState::Read => "read", - _ => return Err(StorageError::Other("invalid message receipt state".into())), - }; - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let Some((message_id, external_user, authored_at, history_deleted)) = tx - .query_row( - "SELECT id, external_user, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, target_signer_id, target_message_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?, row.get::<_, i64>(3)?)), - ) - .optional()? - else { - return Err(StorageError::Other("message receipt target was not found".into())); - }; - if external_user != receipt_signer_id { - return Err(StorageError::Other( - "message receipt signer is not the chat partner".into(), - )); - } - if event_at > recorded_at.saturating_add(5 * 60 * 1000) - || authored_at.is_some_and(|authored_at| event_at < authored_at) - { - return Err(StorageError::Other( - "message receipt event time is outside the accepted clock range".into(), - )); - } - if history_deleted != 0 { - return Ok(()); - } - tx.execute( - "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at], - )?; - let (state_column, recorded_column) = if receipt_type == "read" { - ("read_at", "read_recorded_at") - } else { - ("client_received_at", "client_received_recorded_at") - }; - let state = MessageState::from_str(&tx.query_row( - "SELECT message_state FROM messages WHERE id = ?1", - [message_id], - |row| row.get::<_, String>(0), - )?) - .upgrade(MessageState::from_str(receipt_type)) - .as_str() - .to_string(); - tx.execute( - &format!("UPDATE messages SET {state_column} = COALESCE({state_column}, ?1), {recorded_column} = COALESCE({recorded_column}, ?2), message_state = ?3 WHERE id = ?4"), - params![event_at, recorded_at, state, message_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - message_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn record_destination_iota_received( - storage_owner: i64, - relay_signer_id: i64, - relay_message_id: &str, - accepted_at: i64, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let Some(message_id) = tx - .query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], - |row| row.get::<_, i64>(0), - ) - .optional()? - else { - return Ok(()); - }; - tx.execute( - "UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", - params![accepted_at, message_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - message_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn record_delivery_failure( - storage_owner: i64, - relay_signer_id: i64, - relay_message_id: &str, - failure: &str, - failed_at: i64, -) -> Result<(), StorageError> { - db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let Some(message_id) = tx - .query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], - |row| row.get::<_, i64>(0), - ) - .optional()? - else { - return Ok(()); - }; - tx.execute( - "UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3 AND destination_iota_received_at IS NULL", - params![failed_at, failure, message_id], - )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - message_id, - Operation::Upsert, - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn change_message_state( - timestamp: i64, - storage_owner: i64, - external_user: i64, - new_state: MessageState, -) -> std::io::Result<()> { - db::with_db(|conn| { - let current: Option = match conn.query_row( - r#" - SELECT message_state - FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 - ORDER BY id DESC LIMIT 1 - "#, - params![storage_owner, external_user, timestamp], - |row| row.get(0), - ) { - Ok(state) => Some(state), - Err(rusqlite::Error::QueryReturnedNoRows) => None, - Err(e) => return Err(e.into()), - }; - - let Some(current_state_raw) = current else { - return Ok(()); - }; - - let upgraded = MessageState::from_str(¤t_state_raw) - .upgrade(new_state) - .as_str() - .to_string(); - - let tx = conn.unchecked_transaction()?; - tx.execute( - r#" - UPDATE messages - SET message_state = ?1 - WHERE id = ( - SELECT id FROM messages - WHERE storage_owner = ?2 AND external_user = ?3 AND message_time = ?4 - ORDER BY id DESC LIMIT 1 - ) - "#, - params![upgraded, storage_owner, external_user, timestamp], - )?; - let msg_id: i64 = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", - params![storage_owner, external_user, timestamp], - |row| row.get(0), - )?; - sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?; - tx.commit()?; - Ok(()) - }) - .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) -} - -fn load_reactions( - conn: &rusqlite::Connection, - msg_ids: &[i64], -) -> std::collections::HashMap> { - if msg_ids.is_empty() { - return std::collections::HashMap::new(); - } - - let placeholders: Vec = msg_ids - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", i + 1)) - .collect(); - let query = format!( - "SELECT message_id, reaction, user_id FROM reactions WHERE message_id IN ({}) ORDER BY created_at ASC, id ASC", - placeholders.join(", ") - ); - - let mut map: std::collections::HashMap> = - std::collections::HashMap::new(); - if let Ok(mut stmt) = conn.prepare(&query) { - let params: Vec<&dyn rusqlite::types::ToSql> = msg_ids - .iter() - .map(|id| id as &dyn rusqlite::types::ToSql) - .collect(); - if let Ok(rows) = stmt.query_map(params.as_slice(), |row| { - Ok(( - row.get::<_, i64>(0)?, - StoredReaction { - reaction: row.get(1)?, - user_id: row.get(2)?, - }, - )) - }) { - for row in rows.flatten() { - let reactions = map.entry(row.0).or_default(); - if reactions - .iter() - .any(|stored: &StoredReaction| stored.reaction == row.1.reaction) - { - reactions.push(row.1); - } else if reactions.len() < MAX_UNIQUE_REACTIONS_PER_MESSAGE { - reactions.push(row.1); - } - } - } - } - map -} - -pub fn get_messages( - storage_owner: i64, - external_user: i64, - loaded_messages: i64, - amount: i64, -) -> Vec { - if amount <= 0 || loaded_messages < 0 { - return Vec::new(); - } - - match db::with_db(|conn| { - let mut stmt = conn.prepare( - r#" - SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, - origin_iota_received_at, destination_iota_received_at, - client_received_at, client_received_recorded_at, read_at, - read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, - reply_to, edited_count - FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0 - ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC - LIMIT ?3 OFFSET ?4 - "#, - )?; - - let rows = stmt.query_map( - params![storage_owner, external_user, amount, loaded_messages], - |row| { - Ok(StoredMessage { - id: row.get(0)?, - external_user, - relay_signer_id: row.get(1)?, - relay_message_id: row.get(2)?, - message_time: row.get(3)?, - authored_at: row.get(4)?, - origin_iota_received_at: row.get(5)?, - destination_iota_received_at: row.get(6)?, - client_received_at: row.get(7)?, - client_received_recorded_at: row.get(8)?, - read_at: row.get(9)?, - read_recorded_at: row.get(10)?, - delivery_failed_at: row.get(11)?, - delivery_failure: row.get(12)?, - content: row.get(13)?, - sent_by_self: row.get::<_, i64>(14)? != 0, - message_state: row.get(15)?, - height: row.get(16).unwrap_or(0), - reply_to: row.get(17).ok().flatten(), - edited: row.get::<_, i64>(18).unwrap_or(0) > 0, - reactions: Vec::new(), - }) - }, - )?; - - let mut out = Vec::new(); - for row in rows { - match row { - Ok(msg) => out.push(msg), - Err(e) => log!("Failed to read row from sqlite: {}", e), - } - } - - let msg_ids: Vec = out.iter().map(|m| m.id).collect(); - let reaction_map = load_reactions(conn, &msg_ids); - for msg in &mut out { - msg.reactions = reaction_map.get(&msg.id).cloned().unwrap_or_default(); - } - - Ok(out) - }) { - Ok(v) => v, - Err(e) => { - log!("Failed to query messages: {}", e); - Vec::new() - } - } -} - -pub fn get_message( - storage_owner: i64, - message_time: i64, - external_user: Option, -) -> Result, StorageError> { - db::with_db(|conn| { - let mut stmt = conn.prepare( - r#" - SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, - origin_iota_received_at, destination_iota_received_at, - client_received_at, client_received_recorded_at, read_at, - read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, - reply_to, edited_count, external_user - FROM messages - WHERE storage_owner = ?1 - AND message_time = ?2 - AND deleted_by_external = 0 - AND history_deleted = 0 - AND (?3 IS NULL OR external_user = ?3) - ORDER BY id DESC - "#, - )?; - - let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| { - Ok(StoredMessage { - id: row.get(0)?, - relay_signer_id: row.get(1)?, - relay_message_id: row.get(2)?, - message_time: row.get(3)?, - authored_at: row.get(4)?, - origin_iota_received_at: row.get(5)?, - destination_iota_received_at: row.get(6)?, - client_received_at: row.get(7)?, - client_received_recorded_at: row.get(8)?, - read_at: row.get(9)?, - read_recorded_at: row.get(10)?, - delivery_failed_at: row.get(11)?, - delivery_failure: row.get(12)?, - content: row.get(13)?, - sent_by_self: row.get::<_, i64>(14)? != 0, - message_state: row.get(15)?, - height: row.get(16).unwrap_or(0), - reply_to: row.get(17).ok().flatten(), - edited: row.get::<_, i64>(18).unwrap_or(0) > 0, - external_user: row.get(19)?, - reactions: Vec::new(), - }) - })?; - - let messages: Vec = rows.collect::>()?; - if messages.is_empty() { - return Ok(None); - } - if external_user.is_none() - && messages - .iter() - .map(|message| message.external_user) - .collect::>() - .len() - > 1 - { - return Ok(None); - } - - let mut message = messages.into_iter().next().expect("checked non-empty"); - let reaction_map = load_reactions(conn, &[message.id]); - message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default(); - Ok(Some(message)) - }) -} - -pub fn get_message_with_offset( - storage_owner: i64, - external_user: i64, - message_time: i64, -) -> Result, StorageError> { - let Some(message) = get_message(storage_owner, message_time, Some(external_user))? else { - return Ok(None); - }; - let offset = db::with_db(|conn| { - conn.query_row( - r#" - SELECT COUNT(*) - FROM messages - WHERE storage_owner = ?1 - AND external_user = ?2 - AND deleted_by_external = 0 - AND history_deleted = 0 - AND ( - COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) > - COALESCE(?3, ?4) - OR (COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) = - COALESCE(?3, ?4) AND id > ?4) - ) - "#, - params![ - storage_owner, - external_user, - message.destination_iota_received_at.or(message.origin_iota_received_at).or(message.authored_at), - message.id - ], - |row| row.get(0), - ) - .map_err(StorageError::from) - })?; - Ok(Some((message, offset))) -} - -pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { - if ids.is_empty() { - return Vec::new(); - } - let wanted: std::collections::HashSet = ids.iter().copied().collect(); - // A journal id uniquely identifies a row. Load all messages for this owner and retain only - // those ids; this keeps reaction hydration identical to normal message loading. - match db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?; - let rows = stmt.query_map([storage_owner], |row| { - let external_user: i64 = row.get(19)?; - Ok(StoredMessage { - id: row.get(0)?, - external_user, - relay_signer_id: row.get(1)?, - relay_message_id: row.get(2)?, - message_time: row.get(3)?, - authored_at: row.get(4)?, - origin_iota_received_at: row.get(5)?, - destination_iota_received_at: row.get(6)?, - client_received_at: row.get(7)?, - client_received_recorded_at: row.get(8)?, - read_at: row.get(9)?, - read_recorded_at: row.get(10)?, - delivery_failed_at: row.get(11)?, - delivery_failure: row.get(12)?, - content: row.get(13)?, - sent_by_self: row.get::<_, i64>(14)? != 0, - message_state: row.get(15)?, - height: row.get(16).unwrap_or(0), - reply_to: row.get(17).ok().flatten(), - edited: row.get::<_, i64>(18).unwrap_or(0) > 0, - reactions: Vec::new(), - }) - })?; - let mut messages = Vec::new(); - for row in rows { - let message = row?; - if wanted.contains(&message.id) { - messages.push(message); - } - } - let reaction_map = load_reactions(conn, &messages.iter().map(|m| m.id).collect::>()); - for message in &mut messages { - message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default(); - } - Ok(messages) - }) { - Ok(messages) => messages, - Err(e) => { - log!("Failed to query messages by id: {}", e); - Vec::new() - } - } -} - -pub fn get_all_messages(storage_owner: i64) -> Vec { - let ids = match db::with_db(|conn| { - let mut stmt = conn.prepare( - "SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0", - )?; - Ok(stmt - .query_map([storage_owner], |row| row.get::<_, i64>(0))? - .collect::, _>>()?) - }) { - Ok(ids) => ids, - Err(e) => { - log!("Failed to query all messages: {}", e); - return Vec::new(); - } - }; - get_messages_by_ids(storage_owner, &ids) -} - -#[cfg(test)] -mod tests { - use super::MessageState; - - #[test] - fn upgrade_prefers_highest_state() { - assert_eq!( - MessageState::Sending.upgrade(MessageState::Sent), - MessageState::Sent - ); - assert_eq!( - MessageState::Sent.upgrade(MessageState::Received), - MessageState::Received - ); - assert_eq!( - MessageState::Received.upgrade(MessageState::Read), - MessageState::Read - ); - } - - #[test] - fn from_str_is_case_insensitive() { - assert_eq!(MessageState::from_str("READ"), MessageState::Read); - assert_eq!(MessageState::from_str("received"), MessageState::Received); - assert_eq!(MessageState::from_str("Sent"), MessageState::Sent); - assert_eq!(MessageState::from_str("unknown"), MessageState::Sending); - } -} diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs deleted file mode 100644 index 3eb4043..0000000 --- a/iota-storage/src/util/chats_util.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::storage_error::StorageError; -use crate::users::contact::Contact; -use crate::util::db; -use crate::util::sync::{self, EntityType, Operation}; -use rusqlite::params; - -pub(crate) fn upsert_contact( - tx: &rusqlite::Transaction<'_>, - storage_owner: i64, - contact: &Contact, -) -> Result<(), StorageError> { - tx.execute( - r#" - INSERT INTO contacts (storage_owner, user_id, user_name, created_at, last_message_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(storage_owner, user_id) DO UPDATE SET - user_name = COALESCE(excluded.user_name, contacts.user_name), - created_at = MIN(contacts.created_at, excluded.created_at), - last_message_at = CASE - WHEN excluded.last_message_at IS NULL THEN contacts.last_message_at - WHEN contacts.last_message_at IS NULL THEN excluded.last_message_at - ELSE MAX(contacts.last_message_at, excluded.last_message_at) - END - "#, - params![ - storage_owner, - contact.user_id, - contact.user_name, - contact.created_at, - contact.last_message_at, - ], - )?; - sync::record_event( - tx, - storage_owner, - EntityType::Contact, - contact.user_id, - Operation::Upsert, - )?; - Ok(()) -} - -pub fn has_user(storage_owner: i64, user_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND user_id = ?2)", - params![storage_owner, user_id], - |row| row.get(0), - )?) - }) -} - -pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| upsert_contact(tx, storage_owner, contact)) -} - -pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Result, StorageError> { - if ids.is_empty() { - return Ok(Vec::new()); - } - let wanted: std::collections::HashSet = ids.iter().copied().collect(); - Ok(get_users(storage_owner)? - .into_iter() - .filter(|contact| wanted.contains(&contact.user_id)) - .collect()) -} - -pub fn get_user(storage_owner: i64, user_id: i64) -> Result, StorageError> { - db::with_db(|conn| { - match conn.query_row( - r#" - SELECT user_id, user_name, created_at, last_message_at - FROM contacts - WHERE storage_owner = ?1 AND user_id = ?2 - LIMIT 1 - "#, - params![storage_owner, user_id], - |r| { - Ok(Contact { - user_id: r.get(0)?, - user_name: r.get(1)?, - created_at: r.get(2)?, - last_message_at: r.get(3)?, - }) - }, - ) { - Ok(c) => Ok(Some(c)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - }) -} - -pub fn get_users(storage_owner: i64) -> Result, StorageError> { - db::with_db(|conn| { - let mut stmt = conn.prepare( - r#" - SELECT user_id, user_name, created_at, last_message_at - FROM contacts - WHERE storage_owner = ?1 - ORDER BY - CASE WHEN last_message_at IS NULL THEN 1 ELSE 0 END, - last_message_at DESC, - user_id ASC - "#, - )?; - - let rows = stmt.query_map(params![storage_owner], |r| { - Ok(Contact { - user_id: r.get(0)?, - user_name: r.get(1)?, - created_at: r.get(2)?, - last_message_at: r.get(3)?, - }) - })?; - - let mut out = Vec::new(); - for row in rows { - out.push(row?); - } - Ok(out) - }) -} diff --git a/iota-storage/src/util/client_message_delivery.rs b/iota-storage/src/util/client_message_delivery.rs deleted file mode 100644 index baa4f5f..0000000 --- a/iota-storage/src/util/client_message_delivery.rs +++ /dev/null @@ -1,93 +0,0 @@ -/* Delivery records bind a specific state-sync response to the messages it contained. */ -use crate::storage_error::StorageError; -use crate::util::{chat_files, db, message_storage_policy, sync}; -use rusqlite::{OptionalExtension, params}; - -pub fn record_sync_delivery( - user_id: i64, - session_id: i64, - version: i64, - message_ids: impl IntoIterator, -) -> Result<(), StorageError> { - if user_id <= 0 || session_id <= 0 || version < 0 { - return Err(StorageError::Other( - "invalid message delivery record".into(), - )); - } - db::with_immediate_transaction(|tx| { - for message_id in message_ids { - tx.execute("INSERT OR IGNORE INTO client_message_deliveries (user_id, session_id, sync_version, message_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![user_id, session_id, version, message_id, sync::now_millis()])?; - } - Ok(()) - }) -} - -pub fn acknowledge_sync_delivery( - user_id: i64, - session_id: i64, - version: i64, -) -> Result, StorageError> { - db::with_immediate_transaction(|tx| { - let mut statement = tx.prepare("SELECT DISTINCT message_id FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3")?; - let ids = statement - .query_map(params![user_id, session_id, version], |row| row.get(0))? - .collect::, _>>() - .map_err(StorageError::from)?; - tx.execute("DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3", params![user_id, session_id, version])?; - Ok(ids) - }) -} - -/* Acknowledge a state-sync response and delete only its recorded visible messages atomically. */ -pub fn acknowledge_client_state( - user_id: i64, - session_id: i64, - version: i64, - cache_schema_version: i64, -) -> Result<(), StorageError> { - if user_id <= 0 || session_id <= 0 || version < 0 { - return Err(StorageError::Other("invalid sync acknowledgement".into())); - } - db::with_immediate_transaction(|tx| { - let head = tx - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |row| row.get::<_, i64>(0), - ) - .optional()? - .unwrap_or_default(); - if version > head { - return Err(StorageError::Other( - "acknowledgement is ahead of head".into(), - )); - } - let ids = { - let mut statement = tx.prepare("SELECT DISTINCT message_id FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3")?; - statement - .query_map(params![user_id, session_id, version], |row| { - row.get::<_, i64>(0) - })? - .collect::, _>>()? - }; - tx.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, sync::now_millis()])?; - if message_storage_policy::get_in_tx(tx, user_id)?.history_mode - == message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery - { - for message_id in ids { - chat_files::remove_message_history_in_tx(tx, user_id, message_id)?; - } - } - tx.execute("DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2 AND sync_version <= ?3", params![user_id, session_id, version])?; - Ok(()) - }) -} - -pub fn purge_session_deliveries(user_id: i64, session_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn.execute( - "DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id = ?2", - params![user_id, session_id], - )?) - }) -} diff --git a/iota-storage/src/util/client_relay_delivery.rs b/iota-storage/src/util/client_relay_delivery.rs deleted file mode 100644 index bd72208..0000000 --- a/iota-storage/src/util/client_relay_delivery.rs +++ /dev/null @@ -1,39 +0,0 @@ -/* Commits client delivery, relay state, and retention cleanup as one operation. */ -use crate::storage_error::StorageError; -use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay}; -use rusqlite::OptionalExtension; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClientRelayDeliveryResult { - NotFound, - Acknowledged, -} - -pub fn acknowledge_client_delivery( - destination_id: i64, - frame_id: u32, -) -> Result { - db::with_immediate_transaction(|tx| { - let Some((pending_id, relay)) = - relay_queue::find_user_delivery_in_tx(tx, destination_id, frame_id)? - else { - return Ok(ClientRelayDeliveryResult::NotFound); - }; - let policy = message_storage_policy::get_in_tx(tx, destination_id)?; - relay_replay::mark_delivered_for_frame_in_tx(tx, destination_id, frame_id)?; - if policy.history_mode - == message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery - { - let message_id = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - rusqlite::params![destination_id, relay.signer_id, relay.message_id], - |row| row.get::<_, i64>(0), - ).optional()?; - if let Some(message_id) = message_id { - chat_files::remove_message_history_in_tx(tx, destination_id, message_id)?; - } - } - relay_queue::acknowledge_in_tx(tx, pending_id)?; - Ok(ClientRelayDeliveryResult::Acknowledged) - }) -} diff --git a/iota-storage/src/util/communities_util.rs b/iota-storage/src/util/communities_util.rs deleted file mode 100644 index 3b3be8e..0000000 --- a/iota-storage/src/util/communities_util.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use crate::util::synced_settings::{self, SettingScope}; -use rusqlite::params; - -#[derive(Debug, Clone)] -pub struct StoredCommunity { - pub address: String, - pub title: String, - pub position: String, -} - -pub struct CommunitiesUtil; - -impl CommunitiesUtil { - pub fn has_community(storage_owner: i64, address: &str) -> Result { - db::with_db(|conn| { - Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM communities WHERE storage_owner = ?1 AND address = ?2)", - params![storage_owner, address], - |row| row.get(0), - )?) - }) - } - - pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { - if let Err(e) = db::with_db(|conn| { - conn.execute( - r#" - INSERT INTO communities (storage_owner, address, title, position) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(storage_owner, address) DO UPDATE SET - title = excluded.title, - position = excluded.position - "#, - params![storage_owner, address, title, position], - )?; - Ok(()) - }) { - eprintln!("Failed to add_community: {}", e); - } - } - - pub fn remove_community( - storage_owner: i64, - community_address: String, - ) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - tx.execute( - "DELETE FROM communities WHERE storage_owner = ?1 AND address = ?2", - params![storage_owner, community_address], - )?; - synced_settings::delete_scope_in_tx( - tx, - storage_owner, - SettingScope::Community, - &community_address, - ) - }) - } - - pub fn get_communities(storage_owner: i64) -> Vec { - match db::with_db(|conn| { - let mut stmt = conn.prepare( - r#" - SELECT address, title, position - FROM communities - WHERE storage_owner = ?1 - "#, - )?; - - let rows = stmt.query_map(params![storage_owner], |r| { - Ok(StoredCommunity { - address: r.get(0)?, - title: r.get(1)?, - position: r.get(2)?, - }) - })?; - - let mut out = Vec::new(); - for row in rows { - match row { - Ok(community) => out.push(community), - Err(e) => eprintln!("Failed to read community row: {}", e), - } - } - Ok(out) - }) { - Ok(v) => v, - Err(e) => { - eprintln!("Failed to query communities in get_communities: {}", e); - Vec::new() - } - } - } -} diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs deleted file mode 100644 index b3dfe08..0000000 --- a/iota-storage/src/util/config_util.rs +++ /dev/null @@ -1,243 +0,0 @@ -use arc_swap::ArcSwap; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::OnceLock; - -pub static CONFIG: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default()))); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IotaConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub iota_id: Option, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default)] - pub web: WebSettings, - #[serde(skip_serializing_if = "Option::is_none")] - pub omikron_host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub omikron_port: Option, - #[serde(skip_serializing)] - pub keyring: Option, - #[serde(skip_serializing)] - pub public_key: Option, - #[serde(skip_serializing)] - pub private_key: Option, - #[serde(default = "default_read_receipts_enabled")] - pub read_receipts_enabled: bool, - #[serde(default = "default_max_ipc_clients")] - pub max_ipc_clients: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WebMode { - Disabled, - Loopback, - Network, -} -impl Default for WebMode { - fn default() -> Self { - Self::Disabled - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebSettings { - #[serde(default)] - pub mode: WebMode, - #[serde(default = "default_web_bind")] - pub bind: String, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_web_asset_dir")] - pub asset_dir: String, - pub certificate: Option, - pub key: Option, - #[serde(default)] - pub required: bool, -} -fn default_web_bind() -> String { - "127.0.0.1".into() -} -fn default_web_asset_dir() -> String { - String::new() -} -impl Default for WebSettings { - fn default() -> Self { - Self { - mode: WebMode::default(), - bind: default_web_bind(), - port: default_port(), - asset_dir: default_web_asset_dir(), - certificate: None, - key: None, - required: false, - } - } -} - -const fn default_port() -> u16 { - 1984 -} - -const fn default_read_receipts_enabled() -> bool { - true -} - -const fn default_max_ipc_clients() -> usize { - 64 -} - -impl Default for IotaConfig { - fn default() -> Self { - Self { - iota_id: None, - port: default_port(), - web: WebSettings::default(), - omikron_host: None, - omikron_port: None, - keyring: None, - public_key: None, - private_key: None, - read_receipts_enabled: default_read_receipts_enabled(), - max_ipc_clients: default_max_ipc_clients(), - } - } -} - -pub fn load_config() { - load_config_from(&default_config_path()); -} - -/// Loading is intentionally side-effect free: a missing configuration means -/// documented defaults, not a newly-created file. -pub fn load_config_from(path: &Path) { - let s = match fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, - Err(error) => { - eprintln!("Failed to read {}: {error}", path.display()); - return; - } - }; - - match serde_yaml::from_str::(&s) { - Ok(parsed) => { - CONFIG.store(Arc::new(parsed)); - } - Err(e) => { - eprintln!("Failed to parse {}: {e}", path.display()); - } - } -} - -pub fn clear_config() { - CONFIG.store(Arc::new(IotaConfig::default())); - save_config(); -} - -pub fn save_config() { - save_config_to(&default_config_path()); -} - -pub fn save_config_to(path: &Path) { - if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) { - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - eprintln!( - "Cannot create configuration directory {}: {error}", - parent.display() - ); - return; - } - } - if let Err(error) = iota_util::atomic_file::replace(path, yaml.as_bytes(), 3) { - eprintln!("Cannot save {}: {error}", path.display()); - } - } -} - -fn default_config_path() -> PathBuf { - if let Some(path) = CONFIG_PATH.get() { - return path.clone(); - } - iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .expect("resolve Iota user paths") - .config_file -} - -pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { - let mut cfg = IotaConfig::clone(&**CONFIG.load()); - f(&mut cfg); - CONFIG.store(Arc::new(cfg)); - save_config(); -} - -pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> { - match key { - "iota_id" => { - let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?; - modify_config(|cfg| cfg.iota_id = Some(parsed)); - Ok(()) - } - "port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid port")?; - modify_config(|cfg| cfg.port = parsed); - Ok(()) - } - "omikron_host" => { - let host = value.to_string(); - modify_config(|cfg| cfg.omikron_host = Some(host)); - Ok(()) - } - "omikron_port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?; - modify_config(|cfg| cfg.omikron_port = Some(parsed)); - Ok(()) - } - "read_receipts_enabled" => { - let parsed: bool = value.parse().map_err(|_| "invalid boolean")?; - modify_config(|cfg| cfg.read_receipts_enabled = parsed); - Ok(()) - } - "max_ipc_clients" => { - let parsed: usize = value.parse().map_err(|_| "invalid max_ipc_clients")?; - if parsed == 0 { - return Err("max_ipc_clients must be greater than zero"); - } - modify_config(|cfg| cfg.max_ipc_clients = parsed); - Ok(()) - } - "web.mode" => { - let mode = match value { - "disabled" => WebMode::Disabled, - "loopback" => WebMode::Loopback, - "network" => WebMode::Network, - _ => return Err("invalid web.mode; use disabled, loopback, or network"), - }; - modify_config(|cfg| cfg.web.mode = mode); - Ok(()) - } - "web.port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?; - modify_config(|cfg| cfg.web.port = parsed); - Ok(()) - } - "web.bind" => { - let bind = value.to_string(); - modify_config(|cfg| cfg.web.bind = bind); - Ok(()) - } - _ => Err("unknown config key"), - } -} -static CONFIG_PATH: OnceLock = OnceLock::new(); - -pub fn configure_config_path(path: PathBuf) { - let _ = CONFIG_PATH.set(path); -} diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs deleted file mode 100644 index c983324..0000000 --- a/iota-storage/src/util/db.rs +++ /dev/null @@ -1,858 +0,0 @@ -use once_cell::sync::Lazy; -use r2d2::ManageConnection; -use rusqlite::{Connection, Transaction}; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use crate::storage_error::StorageError; - -const DB_NAME: &str = "messages"; - -/// A simple r2d2 manager for rusqlite connections. -pub struct SqliteManager; - -impl ManageConnection for SqliteManager { - type Connection = Connection; - type Error = rusqlite::Error; - - fn connect(&self) -> Result { - let path = db_file_path(DB_NAME); - let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; - conn.busy_timeout(Duration::from_millis(250))?; - Ok(conn) - } - - fn is_valid(&self, conn: &mut Connection) -> Result<(), rusqlite::Error> { - conn.execute_batch("SELECT 1") - } - - fn has_broken(&self, _conn: &mut Connection) -> bool { - false - } -} - -static POOL: Lazy>> = Lazy::new(|| { - let manager = SqliteManager; - let pool = r2d2::Pool::builder() - .max_size(8) - .build(manager) - .expect("Failed to create database connection pool"); - run_migrations(&pool).expect("Failed to run database migrations"); - Arc::new(pool) -}); - -pub fn pool() -> Arc> { - POOL.clone() -} - -pub fn with_db(f: F) -> Result -where - F: FnOnce(&Connection) -> Result, -{ - let conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; - f(&conn) -} - -pub fn with_immediate_transaction(f: F) -> Result -where - F: FnOnce(&Transaction<'_>) -> Result, -{ - let mut conn = POOL.get().map_err(|e| StorageError::Pool(e.to_string()))?; - let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; - let value = f(&tx)?; - tx.commit()?; - Ok(value) -} - -/* Verify the persistent database before the pool is initialized. A corrupt - * database is moved aside rather than opened again, preserving material for - * operator recovery while allowing the daemon to report the failed storage. */ -pub fn verify_and_backup_database() -> Result<(), StorageError> { - let storage_dir = iota_util::file_util::storage_directory(); - std::fs::create_dir_all(&storage_dir)?; - let path = storage_dir.join(format!("{DB_NAME}.sqlite3")); - if !path.exists() { - return Ok(()); - } - - let connection = Connection::open(&path)?; - connection.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;")?; - connection.execute_batch("PRAGMA wal_checkpoint(FULL);")?; - let integrity: String = connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; - drop(connection); - if integrity != "ok" { - let recovery = storage_dir.join("recovery"); - std::fs::create_dir_all(&recovery)?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - for suffix in ["", "-wal", "-shm"] { - let source = PathBuf::from(format!("{}{}", path.display(), suffix)); - if source.exists() { - let destination = recovery.join(format!("{DB_NAME}.sqlite3.{timestamp}{suffix}")); - std::fs::rename(source, destination)?; - } - } - return Err(StorageError::Other(format!( - "database integrity check failed ({integrity}); moved database files to {}", - recovery.display() - ))); - } - - let backup_dir = storage_dir.join("backups"); - std::fs::create_dir_all(&backup_dir)?; - let backup = backup_dir.join(format!("{DB_NAME}.sqlite3")); - let temporary = backup_dir.join(format!(".{DB_NAME}.sqlite3.tmp")); - std::fs::copy(&path, &temporary)?; - std::fs::File::open(&temporary)?.sync_all()?; - std::fs::rename(temporary, backup)?; - Ok(()) -} - -fn db_file_path(db_name: &str) -> PathBuf { - let storage_dir = iota_util::file_util::storage_directory(); - // Creating storage belongs to initialization/connection setup, never to a - // configuration read. - std::fs::create_dir_all(&storage_dir).expect("create Iota storage directory"); - storage_dir.join(format!("{db_name}.sqlite3")) -} - -fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> { - let conn = pool.get().map_err(|e| StorageError::Pool(e.to_string()))?; - run_migrations_on_connection(&conn) -} - -/* - * Older builds could apply a schema change without advancing user_version. - * Check each added column so those databases can resume upgrading. - */ -fn add_column_if_missing( - conn: &Connection, - column: &str, - definition: &str, -) -> Result<(), StorageError> { - add_table_column_if_missing(conn, "messages", column, definition) -} - -fn add_table_column_if_missing( - conn: &Connection, - table: &str, - column: &str, - definition: &str, -) -> Result<(), StorageError> { - let mut statement = conn.prepare(&format!( - "SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1" - ))?; - let exists = statement.exists([column])?; - - if !exists { - conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {definition};"))?; - } - - Ok(()) -} - -fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { - let current_version: i64 = conn - .pragma_query_value(None, "user_version", |r| r.get(0)) - .unwrap_or(0); - - if current_version < 1 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - external_user INTEGER NOT NULL, - message_time INTEGER NOT NULL, - content TEXT NOT NULL, - sent_by_self INTEGER NOT NULL, - message_state TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_messages_lookup - ON messages (storage_owner, external_user, message_time DESC); - - CREATE TABLE IF NOT EXISTS contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - user_id INTEGER NOT NULL, - user_name TEXT, - created_at INTEGER NOT NULL, - last_message_at INTEGER, - UNIQUE(storage_owner, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_contacts_owner - ON contacts (storage_owner, last_message_at DESC, user_id ASC); - - CREATE TABLE IF NOT EXISTS communities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - address TEXT NOT NULL, - title TEXT NOT NULL, - position TEXT NOT NULL, - UNIQUE(storage_owner, address) - ); - CREATE INDEX IF NOT EXISTS idx_communities_owner - ON communities (storage_owner); - - CREATE TABLE IF NOT EXISTS users ( - user_id INTEGER PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - public_key TEXT NOT NULL, - private_key_hash TEXT NOT NULL, - reset_token TEXT NOT NULL, - created_at INTEGER NOT NULL, - display_name TEXT - ); - - CREATE TABLE IF NOT EXISTS trusted_apps ( - user_id INTEGER NOT NULL, - app_id TEXT NOT NULL, - app_secret TEXT NOT NULL, - PRIMARY KEY (user_id, app_id) - ); - - PRAGMA user_version = 1; - "#, - )?; - } - - if current_version < 2 { - add_column_if_missing(conn, "height", "height INTEGER NOT NULL DEFAULT 0")?; - conn.execute_batch("PRAGMA user_version = 2;")?; - } - - if current_version < 3 { - add_column_if_missing(conn, "reply_to", "reply_to INTEGER")?; - conn.execute_batch("PRAGMA user_version = 3;")?; - } - - if current_version < 4 { - add_column_if_missing( - conn, - "edited_count", - "edited_count INTEGER NOT NULL DEFAULT 0", - )?; - add_column_if_missing( - conn, - "deleted_by_external", - "deleted_by_external INTEGER NOT NULL DEFAULT 0", - )?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS message_edits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES messages(id), - content_before TEXT NOT NULL, - content_after TEXT NOT NULL, - edited_at INTEGER NOT NULL, - edited_by INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_message_edits_msg - ON message_edits (message_id, edited_at DESC); - - CREATE TABLE IF NOT EXISTS reactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES messages(id), - user_id INTEGER NOT NULL, - reaction TEXT NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(message_id, user_id, reaction) - ); - CREATE INDEX IF NOT EXISTS idx_reactions_msg - ON reactions (message_id, reaction); - - PRAGMA user_version = 4; - "#, - )?; - } - - if current_version < 5 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS settings ( - user_id INTEGER NOT NULL, - session_id INTEGER NOT NULL, - name TEXT NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (user_id, session_id, name) - ); - CREATE INDEX IF NOT EXISTS idx_settings_lookup - ON settings (user_id, session_id, name); - - PRAGMA user_version = 5; - "#, - )?; - } - - if current_version < 6 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS sync_heads ( - user_id INTEGER PRIMARY KEY, - version INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS sync_events ( - user_id INTEGER NOT NULL, - version INTEGER NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - operation TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, version) - ); - CREATE INDEX IF NOT EXISTS idx_sync_events_user_version - ON sync_events (user_id, version); - CREATE TABLE IF NOT EXISTS client_sync_state ( - user_id INTEGER NOT NULL, - session_id INTEGER NOT NULL, - acknowledged_version INTEGER NOT NULL, - cache_schema_version INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (user_id, session_id) - ); - PRAGMA user_version = 6; - "#, - )?; - } - - if current_version < 7 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS user_residency ( - user_id INTEGER PRIMARY KEY, - username TEXT NOT NULL, - lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')), - data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')), - updated_at INTEGER NOT NULL - ); - PRAGMA user_version = 7; - "#, - )?; - } - - if current_version < 8 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS relay_replay ( - signer_id INTEGER NOT NULL, - message_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (signer_id, message_id) - ); - CREATE INDEX IF NOT EXISTS idx_relay_replay_created_at - ON relay_replay (created_at); - CREATE TABLE IF NOT EXISTS pending_relays ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - destination_id INTEGER NOT NULL, - target_kind INTEGER NOT NULL DEFAULT 0, - frame BLOB NOT NULL, - created_at INTEGER NOT NULL, - frame_id INTEGER NOT NULL DEFAULT 0, - UNIQUE(destination_id, frame) - ); - CREATE INDEX IF NOT EXISTS idx_pending_relays_destination - ON pending_relays (destination_id, id); - PRAGMA user_version = 8; - "#, - )?; - } - - if current_version < 9 { - add_table_column_if_missing( - conn, - "pending_relays", - "target_kind", - "target_kind INTEGER NOT NULL DEFAULT 0", - )?; - add_table_column_if_missing( - conn, - "pending_relays", - "type_map_version", - "type_map_version TEXT NOT NULL DEFAULT '1.0'", - )?; - add_table_column_if_missing( - conn, - "pending_relays", - "frame_id", - "frame_id INTEGER NOT NULL DEFAULT 0", - )?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS relay_inbox ( - signer_id INTEGER NOT NULL, - message_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - destination_id INTEGER NOT NULL, - frame BLOB NOT NULL, - type_map_version TEXT NOT NULL, - frame_id INTEGER NOT NULL, - state TEXT NOT NULL CHECK (state IN ('received', 'applied', 'queued', 'delivered', 'rejected')), - PRIMARY KEY (signer_id, message_id) - ); - CREATE INDEX IF NOT EXISTS idx_relay_inbox_state - ON relay_inbox (state, created_at); - PRAGMA user_version = 9; - "#, - )?; - } - - if current_version < 10 { - conn.execute_batch( - r#" - CREATE INDEX IF NOT EXISTS idx_messages_history - ON messages ( - storage_owner, - external_user, - deleted_by_external, - message_time DESC, - id DESC - ); - PRAGMA user_version = 10; - "#, - )?; - } - - if current_version < 11 { - for (column, definition) in [ - ("relay_signer_id", "relay_signer_id INTEGER"), - ("relay_message_id", "relay_message_id TEXT"), - ("authored_at", "authored_at INTEGER"), - ("origin_iota_received_at", "origin_iota_received_at INTEGER"), - ( - "destination_iota_received_at", - "destination_iota_received_at INTEGER", - ), - ("client_received_at", "client_received_at INTEGER"), - ( - "client_received_recorded_at", - "client_received_recorded_at INTEGER", - ), - ("read_at", "read_at INTEGER"), - ("read_recorded_at", "read_recorded_at INTEGER"), - ] { - add_column_if_missing(conn, column, definition)?; - } - for (column, definition) in [ - ("accepted_at", "accepted_at INTEGER"), - ("applied_at", "applied_at INTEGER"), - ("queued_at", "queued_at INTEGER"), - ("downstream_acked_at", "downstream_acked_at INTEGER"), - ("rejected_at", "rejected_at INTEGER"), - ] { - add_table_column_if_missing(conn, "relay_inbox", column, definition)?; - } - conn.execute_batch( - r#" - CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_relay_identity - ON messages (storage_owner, relay_signer_id, relay_message_id) - WHERE relay_message_id IS NOT NULL; - CREATE TABLE IF NOT EXISTS message_receipts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - target_signer_id INTEGER NOT NULL, - target_message_id TEXT NOT NULL, - receipt_signer_id INTEGER NOT NULL, - receipt_message_id TEXT NOT NULL, - receipt_type TEXT NOT NULL CHECK (receipt_type IN ('received', 'read')), - event_at INTEGER NOT NULL, - recorded_at INTEGER NOT NULL, - UNIQUE(receipt_signer_id, receipt_message_id), - UNIQUE(storage_owner, target_signer_id, target_message_id, - receipt_signer_id, receipt_type) - ); - CREATE INDEX IF NOT EXISTS idx_message_receipts_target - ON message_receipts (storage_owner, target_signer_id, target_message_id); - PRAGMA user_version = 11; - "#, - )?; - } - - if current_version < 12 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS synced_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - scope_type TEXT NOT NULL - CHECK (scope_type IN ('user', 'contact', 'community')), - scope_key TEXT NOT NULL, - name TEXT NOT NULL, - payload TEXT NOT NULL, - revision INTEGER NOT NULL, - deleted INTEGER NOT NULL DEFAULT 0 - CHECK (deleted IN (0, 1)), - UNIQUE(user_id, scope_type, scope_key, name) - ); - CREATE INDEX IF NOT EXISTS idx_synced_settings_owner - ON synced_settings (user_id, deleted); - CREATE INDEX IF NOT EXISTS idx_synced_settings_scope - ON synced_settings (user_id, scope_type, scope_key, deleted); - PRAGMA user_version = 12; - "#, - )?; - } - - if current_version < 13 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS pending_user_operations ( - user_id INTEGER PRIMARY KEY, - operation TEXT NOT NULL - CHECK (operation IN ('create', 'attach', 'release')), - username TEXT NOT NULL, - public_key TEXT, - private_key_hash TEXT, - reset_token TEXT, - registration_token TEXT, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_pending_user_operations_operation - ON pending_user_operations (operation, created_at); - PRAGMA user_version = 13; - "#, - )?; - } - - if current_version < 14 { - conn.execute_batch( - r#" - ALTER TABLE pending_user_operations - ADD COLUMN phase TEXT NOT NULL DEFAULT 'prepared' - CHECK (phase IN ('prepared', 'credential_written', 'remote_committed', 'local_committed')); - PRAGMA user_version = 14; - "#, - )?; - } - - if current_version < 15 { - let messages_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", - [], - |row| row.get(0), - )?; - if messages_exist { - conn.execute_batch( - r#" - DROP INDEX IF EXISTS idx_messages_history; - CREATE INDEX IF NOT EXISTS idx_messages_history_accepted - ON messages ( - storage_owner, - external_user, - deleted_by_external, - destination_iota_received_at DESC, - origin_iota_received_at DESC, - authored_at DESC, - id DESC - ); - "#, - )?; - } - conn.pragma_update(None, "user_version", 15)?; - } - - if current_version < 16 { - let messages_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", - [], - |row| row.get(0), - )?; - if messages_exist { - add_column_if_missing(conn, "delivery_failed_at", "delivery_failed_at INTEGER")?; - add_column_if_missing(conn, "delivery_failure", "delivery_failure TEXT")?; - } - let contacts_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'contacts')", - [], - |row| row.get(0), - )?; - if contacts_exist { - add_table_column_if_missing(conn, "contacts", "created_at", "created_at INTEGER")?; - conn.execute( - "UPDATE contacts SET created_at = COALESCE(created_at, last_message_at, 0)", - [], - )?; - } - conn.pragma_update(None, "user_version", 16)?; - } - - if current_version < 17 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS user_blobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - blob_id TEXT NOT NULL, - blob BLOB NOT NULL, - revision INTEGER NOT NULL, - deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)), - updated_at INTEGER NOT NULL, - UNIQUE(user_id, blob_id) - ); - CREATE INDEX IF NOT EXISTS idx_user_blobs_owner - ON user_blobs (user_id, deleted); - CREATE TABLE IF NOT EXISTS blocked_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - blocked_user_id INTEGER NOT NULL, - revision INTEGER NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(user_id, blocked_user_id) - ); - CREATE INDEX IF NOT EXISTS idx_blocked_users_owner ON blocked_users (user_id); - CREATE TABLE IF NOT EXISTS user_receipt_policy ( - user_id INTEGER PRIMARY KEY, - send_read_receipts INTEGER NOT NULL CHECK (send_read_receipts IN (0, 1)), - send_received_receipts INTEGER NOT NULL CHECK (send_received_receipts IN (0, 1)), - revision INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS user_message_storage_policy ( - user_id INTEGER PRIMARY KEY, - history_mode TEXT NOT NULL CHECK (history_mode IN ('retain', 'delete_after_client_delivery')), - retention_mode TEXT NOT NULL CHECK (retention_mode IN ('forever', 'duration')), - retention_duration_ms INTEGER, - revision INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - PRAGMA user_version = 17; - "#, - )?; - } - - if current_version < 18 { - let messages_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", - [], - |row| row.get(0), - )?; - if messages_exist { - add_column_if_missing(conn, "stored_at", "stored_at INTEGER")?; - add_column_if_missing(conn, "expires_at", "expires_at INTEGER")?; - conn.execute_batch( - "CREATE INDEX IF NOT EXISTS idx_messages_expiry ON messages (storage_owner, expires_at) WHERE expires_at IS NOT NULL;", - )?; - } - conn.pragma_update(None, "user_version", 18)?; - } - - if current_version < 19 { - let messages_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", - [], - |row| row.get(0), - )?; - if messages_exist { - conn.execute( - "UPDATE messages SET stored_at = COALESCE(destination_iota_received_at, origin_iota_received_at) WHERE stored_at IS NULL", - [], - )?; - } - conn.pragma_update(None, "user_version", 19)?; - } - - if current_version < 20 { - let messages_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", - [], - |row| row.get(0), - )?; - if messages_exist { - add_column_if_missing( - conn, - "history_deleted", - "history_deleted INTEGER NOT NULL DEFAULT 0 CHECK (history_deleted IN (0, 1))", - )?; - add_column_if_missing(conn, "history_deleted_at", "history_deleted_at INTEGER")?; - } - let pending_relays_exist: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_relays')", - [], - |row| row.get(0), - )?; - if pending_relays_exist { - add_table_column_if_missing( - conn, - "pending_relays", - "relay_signer_id", - "relay_signer_id INTEGER", - )?; - add_table_column_if_missing( - conn, - "pending_relays", - "relay_destination_user_id", - "relay_destination_user_id INTEGER", - )?; - add_table_column_if_missing( - conn, - "pending_relays", - "relay_message_id", - "relay_message_id TEXT", - )?; - } - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS client_message_deliveries ( - user_id INTEGER NOT NULL, - session_id INTEGER NOT NULL, - sync_version INTEGER NOT NULL, - message_id INTEGER NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, session_id, sync_version, message_id) - ); - CREATE INDEX IF NOT EXISTS idx_client_message_deliveries_session - ON client_message_deliveries (user_id, session_id, sync_version); - PRAGMA user_version = 20; - "#, - )?; - } - - Ok(()) -} - -pub fn open_connection(db_name: &str) -> Result { - let path = db_file_path(db_name); - Connection::open(path) -} - -pub fn create_shared_connection( - db_name: &str, - init_sql: &str, -) -> Result>, String> { - let path = db_file_path(db_name); - let conn = Connection::open(path).map_err(|e| e.to_string())?; - conn.execute_batch(init_sql).map_err(|e| e.to_string())?; - let _ = conn.busy_timeout(Duration::from_millis(250)); - Ok(Arc::new(std::sync::Mutex::new(conn))) -} - -pub fn with_conn(shared: &Arc>, f: F) -> Result -where - F: FnOnce(&Connection) -> Result, -{ - if tokio::runtime::Handle::try_current().is_ok() { - tokio::task::block_in_place(|| { - let guard = shared - .lock() - .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; - f(&*guard).map_err(|e| e.to_string()) - }) - } else { - let guard = shared - .lock() - .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; - f(&*guard).map_err(|e| e.to_string()) - } -} - -/// Legacy - kept for e2ee_storage which uses its own DB. -pub fn create_general_messages_db() -> Result>, String> { - create_shared_connection(DB_NAME, "") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resumes_migration_when_height_exists_before_its_version() -> Result<(), StorageError> { - let conn = Connection::open_in_memory()?; - conn.execute_batch( - r#" - CREATE TABLE messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - storage_owner INTEGER NOT NULL, - external_user INTEGER NOT NULL, - message_time INTEGER NOT NULL, - content TEXT NOT NULL, - sent_by_self INTEGER NOT NULL, - message_state TEXT NOT NULL - ); - ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0; - PRAGMA user_version = 1; - "#, - )?; - - run_migrations_on_connection(&conn)?; - - let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); - for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { - let mut statement = - conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; - assert!(statement.exists([column])?); - } - - Ok(()) - } - - #[test] - fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> { - let conn = Connection::open_in_memory()?; - conn.execute_batch( - "CREATE TABLE messages (id INTEGER PRIMARY KEY, storage_owner INTEGER NOT NULL, external_user INTEGER NOT NULL, message_time INTEGER NOT NULL, content TEXT NOT NULL, sent_by_self INTEGER NOT NULL, message_state TEXT NOT NULL, height INTEGER NOT NULL DEFAULT 0, reply_to INTEGER, edited_count INTEGER NOT NULL DEFAULT 0, deleted_by_external INTEGER NOT NULL DEFAULT 0); PRAGMA user_version = 5;", - )?; - run_migrations_on_connection(&conn)?; - run_migrations_on_connection(&conn)?; - let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); - for table in [ - "sync_heads", - "sync_events", - "client_sync_state", - "user_residency", - "relay_replay", - "pending_relays", - "relay_inbox", - "synced_settings", - "pending_user_operations", - "user_blobs", - "blocked_users", - "user_receipt_policy", - "user_message_storage_policy", - ] { - let exists: i64 = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", - [table], - |row| row.get(0), - )?; - assert_eq!(exists, 1); - } - for column in ["frame_id", "target_kind", "type_map_version"] { - let mut statement = - conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?; - assert!(statement.exists([column])?); - } - Ok(()) - } - - #[test] - fn adds_synced_settings_to_a_version_eleven_schema() -> Result<(), StorageError> { - let conn = Connection::open_in_memory()?; - conn.execute_batch("PRAGMA user_version = 11;")?; - - run_migrations_on_connection(&conn)?; - - let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 20); - for column in [ - "id", - "user_id", - "scope_type", - "scope_key", - "name", - "payload", - "revision", - "deleted", - ] { - let mut statement = - conn.prepare("SELECT 1 FROM pragma_table_info('synced_settings') WHERE name = ?1")?; - assert!(statement.exists([column])?); - } - - Ok(()) - } -} diff --git a/iota-storage/src/util/downstream_relay.rs b/iota-storage/src/util/downstream_relay.rs deleted file mode 100644 index b1eddf2..0000000 --- a/iota-storage/src/util/downstream_relay.rs +++ /dev/null @@ -1,69 +0,0 @@ -/* Completes or rejects an Iota-to-Iota relay without leaving retry state behind. */ -use crate::storage_error::StorageError; -use crate::util::{db, sync}; -use rusqlite::{OptionalExtension, params}; - -pub fn acknowledge_iota_delivery( - destination_iota: u64, - frame_id: u32, - signer_id: i64, - relay_message_id: &str, - destination_accepted_at: i64, -) -> Result<(), StorageError> { - let destination_iota = i64::try_from(destination_iota) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_immediate_transaction(|tx| { - let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?; - let Some(pending_id) = pending_id else { - return Ok(()); - }; - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?; - if let Some(message_id) = message_id { - tx.execute("UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![destination_accepted_at, message_id])?; - sync::record_event( - tx, - signer_id, - sync::EntityType::Message, - message_id, - sync::Operation::Upsert, - )?; - } - tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?; - tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?; - Ok(()) - }) -} - -pub fn reject_iota_delivery( - destination_iota: u64, - frame_id: u32, - signer_id: i64, - relay_message_id: &str, - failure: &str, -) -> Result<(), StorageError> { - let destination_iota = i64::try_from(destination_iota) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_immediate_transaction(|tx| { - let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?; - let Some(pending_id) = pending_id else { - return Ok(()); - }; - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?; - if let Some(message_id) = message_id { - tx.execute( - "UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3", - params![sync::now_millis(), failure, message_id], - )?; - sync::record_event( - tx, - signer_id, - sync::EntityType::Message, - message_id, - sync::Operation::Upsert, - )?; - } - tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?; - tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?; - Ok(()) - }) -} diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs deleted file mode 100644 index 55cf8cd..0000000 --- a/iota-storage/src/util/e2ee_storage.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::util::db; -use rusqlite::{OptionalExtension, params}; -use std::sync::{Arc, LazyLock, Mutex}; - -pub type StorageError = String; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredChatSecret { - pub user_id: String, - pub chat_id: String, - pub secret_id: String, - pub version: i64, - pub encrypted_secret: Vec, - pub kem_ciphertext: Vec, - pub wrapping_scheme: String, - pub created_at: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Default)] -pub struct ChatSecretQuery { - pub user_id: String, - pub chat_id: String, - pub secret_id: Option, -} - -static E2EE_DB: LazyLock>> = LazyLock::new(|| { - db::create_shared_connection( - "e2ee", - r#" - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - - DROP TABLE IF EXISTS encrypted_messages; - DROP TABLE IF EXISTS encrypted_device_secrets; - - CREATE TABLE IF NOT EXISTS chat_secrets ( - user_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - secret_id TEXT NOT NULL, - version INTEGER NOT NULL, - encrypted_secret BLOB NOT NULL, - kem_ciphertext BLOB NOT NULL, - wrapping_scheme TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (user_id, chat_id, secret_id) - ); - - CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner - ON chat_secrets (user_id, chat_id, secret_id); - - "#, - ) - .expect("Failed to create or initialize E2EE DB") -}); - -pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - INSERT INTO chat_secrets ( - user_id, chat_id, secret_id, version, encrypted_secret, - kem_ciphertext, wrapping_scheme, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(user_id, chat_id, secret_id) DO UPDATE SET - version = excluded.version, - encrypted_secret = excluded.encrypted_secret, - kem_ciphertext = excluded.kem_ciphertext, - wrapping_scheme = excluded.wrapping_scheme, - created_at = excluded.created_at, - updated_at = excluded.updated_at - "#, - params![ - record.user_id, - record.chat_id, - record.secret_id, - record.version, - record.encrypted_secret, - record.kem_ciphertext, - record.wrapping_scheme, - record.created_at, - record.updated_at, - ], - )?; - Ok(()) - }) -} - -/// Erase every E2EE record owned by a user. The operation is -/// intentionally idempotent so it can be retried after an interrupted remote -/// erasure request. -pub fn purge_user(user_id: i64) -> Result<(), StorageError> { - let user_id = user_id.to_string(); - db::with_conn(&E2EE_DB, |conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( - "DELETE FROM chat_secrets WHERE user_id = ?1", - params![user_id], - )?; - tx.commit()?; - Ok(()) - }) -} - -pub fn get_chat_secret(query: ChatSecretQuery) -> Result, StorageError> { - if query.user_id.is_empty() || query.chat_id.is_empty() { - return Ok(None); - } - - db::with_conn(&E2EE_DB, |conn| { - conn.query_row( - r#" - SELECT user_id, chat_id, secret_id, version, encrypted_secret, - kem_ciphertext, wrapping_scheme, created_at, updated_at - FROM chat_secrets - WHERE user_id = ?1 - AND chat_id = ?2 - AND (?3 IS NULL OR secret_id = ?3) - ORDER BY updated_at DESC - LIMIT 1 - "#, - params![query.user_id, query.chat_id, query.secret_id], - chat_secret_from_row, - ) - .optional() - }) -} - -fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(StoredChatSecret { - user_id: row.get(0)?, - chat_id: row.get(1)?, - secret_id: row.get(2)?, - version: row.get(3)?, - encrypted_secret: row.get(4)?, - kem_ciphertext: row.get(5)?, - wrapping_scheme: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) -} diff --git a/iota-storage/src/util/message_retention.rs b/iota-storage/src/util/message_retention.rs deleted file mode 100644 index 032e54b..0000000 --- a/iota-storage/src/util/message_retention.rs +++ /dev/null @@ -1,28 +0,0 @@ -/* Retention cleanup removes only stored history; pending transport is retained. */ -use crate::storage_error::StorageError; -use crate::util::{chat_files, db}; - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct RetentionResult { - pub deleted_messages: usize, -} - -pub fn purge_expired_messages(now: i64) -> Result { - let expired = db::with_db(|conn| { - let mut statement = conn.prepare( - "SELECT id, storage_owner FROM messages WHERE expires_at IS NOT NULL AND expires_at <= ?1 ORDER BY expires_at ASC", - )?; - statement - .query_map([now], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) - })? - .collect::, _>>() - .map_err(StorageError::from) - })?; - let mut result = RetentionResult::default(); - for (message_id, storage_owner) in expired { - chat_files::purge_message(storage_owner, message_id)?; - result.deleted_messages += 1; - } - Ok(result) -} diff --git a/iota-storage/src/util/message_storage_policy.rs b/iota-storage/src/util/message_storage_policy.rs deleted file mode 100644 index 0d9568f..0000000 --- a/iota-storage/src/util/message_storage_policy.rs +++ /dev/null @@ -1,149 +0,0 @@ -/* Retention policy is normalized so invalid combinations cannot reach cleanup logic. */ -use crate::storage_error::StorageError; -use crate::util::{db, sync}; -use rusqlite::{OptionalExtension, Transaction}; - -pub const MIN_RETENTION_DURATION_MS: i64 = 60_000; -pub const MAX_RETENTION_DURATION_MS: i64 = 31_536_000_000; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageHistoryMode { - Retain, - DeleteAfterClientDelivery, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageRetention { - Forever, - Duration { duration_ms: i64 }, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MessageStoragePolicy { - pub user_id: i64, - pub history_mode: MessageHistoryMode, - pub retention: MessageRetention, - pub revision: i64, - pub updated_at: i64, -} -pub fn default_policy() -> MessageStoragePolicy { - MessageStoragePolicy { - user_id: 0, - history_mode: MessageHistoryMode::Retain, - retention: MessageRetention::Forever, - revision: 0, - updated_at: 0, - } -} -fn validate(user_id: i64, retention: MessageRetention) -> Result<(), StorageError> { - if user_id <= 0 { - return Err(StorageError::Other( - "invalid message storage policy owner".into(), - )); - } - if let MessageRetention::Duration { duration_ms } = retention { - if !(MIN_RETENTION_DURATION_MS..=MAX_RETENTION_DURATION_MS).contains(&duration_ms) { - return Err(StorageError::Other("invalid retention duration".into())); - } - } - Ok(()) -} -fn history_name(value: MessageHistoryMode) -> &'static str { - match value { - MessageHistoryMode::Retain => "retain", - MessageHistoryMode::DeleteAfterClientDelivery => "delete_after_client_delivery", - } -} -fn parse_history(value: &str) -> Result { - match value { - "retain" => Ok(MessageHistoryMode::Retain), - "delete_after_client_delivery" => Ok(MessageHistoryMode::DeleteAfterClientDelivery), - _ => Err(StorageError::Other( - "invalid stored message history mode".into(), - )), - } -} -pub fn get(user_id: i64) -> Result { - validate(user_id, MessageRetention::Forever)?; - db::with_db(|conn| { - let found = conn.query_row("SELECT history_mode, retention_mode, retention_duration_ms, revision, updated_at FROM user_message_storage_policy WHERE user_id = ?1", [user_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(4)?))).optional()?; - policy_from_found(user_id, found) - }) -} - -pub fn get_in_tx(tx: &Transaction<'_>, user_id: i64) -> Result { - validate(user_id, MessageRetention::Forever)?; - let found = tx.query_row("SELECT history_mode, retention_mode, retention_duration_ms, revision, updated_at FROM user_message_storage_policy WHERE user_id = ?1", [user_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(4)?))).optional()?; - policy_from_found(user_id, found) -} - -fn policy_from_found( - user_id: i64, - found: Option<(String, String, Option, i64, i64)>, -) -> Result { - match found { - None => Ok(MessageStoragePolicy { - user_id, - ..default_policy() - }), - Some((history, mode, duration, revision, updated_at)) => { - let retention = match (mode.as_str(), duration) { - ("forever", None) => MessageRetention::Forever, - ("duration", Some(duration_ms)) => MessageRetention::Duration { duration_ms }, - _ => { - return Err(StorageError::Other( - "invalid stored retention policy".into(), - )); - } - }; - validate(user_id, retention)?; - Ok(MessageStoragePolicy { - user_id, - history_mode: parse_history(&history)?, - retention, - revision, - updated_at, - }) - } - } -} -pub fn set( - user_id: i64, - history_mode: MessageHistoryMode, - retention: MessageRetention, -) -> Result { - validate(user_id, retention)?; - db::with_immediate_transaction(|tx| { - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::MessageStoragePolicy, - user_id, - sync::Operation::Upsert, - )?; - let updated_at = sync::now_millis(); - let (retention_mode, duration): (&str, Option) = match retention { - MessageRetention::Forever => ("forever", None), - MessageRetention::Duration { duration_ms } => ("duration", Some(duration_ms)), - }; - tx.execute("INSERT INTO user_message_storage_policy (user_id, history_mode, retention_mode, retention_duration_ms, revision, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(user_id) DO UPDATE SET history_mode = excluded.history_mode, retention_mode = excluded.retention_mode, retention_duration_ms = excluded.retention_duration_ms, revision = excluded.revision, updated_at = excluded.updated_at", rusqlite::params![user_id, history_name(history_mode), retention_mode, duration, revision, updated_at])?; - match retention { - MessageRetention::Forever => { - tx.execute( - "UPDATE messages SET expires_at = NULL WHERE storage_owner = ?1", - [user_id], - )?; - } - MessageRetention::Duration { duration_ms } => { - tx.execute( - "UPDATE messages SET expires_at = stored_at + ?2 WHERE storage_owner = ?1 AND stored_at IS NOT NULL", - rusqlite::params![user_id, duration_ms], - )?; - } - } - Ok(MessageStoragePolicy { - user_id, - history_mode, - retention, - revision, - updated_at, - }) - }) -} diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs deleted file mode 100644 index bcf195e..0000000 --- a/iota-storage/src/util/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod blocked_users; -pub mod chat_files; -pub mod chats_util; -pub mod client_message_delivery; -pub mod client_relay_delivery; -pub mod communities_util; -pub mod config_util; -pub mod db; -pub mod downstream_relay; -pub mod e2ee_storage; -pub mod message_retention; -pub mod message_storage_policy; -pub mod outgoing_relay; -pub mod receipt_policy; -pub mod relay_queue; -pub mod relay_replay; -pub mod settings; -pub mod sync; -pub mod synced_settings; -pub mod user_blobs; diff --git a/iota-storage/src/util/outgoing_relay.rs b/iota-storage/src/util/outgoing_relay.rs deleted file mode 100644 index 0ea7e17..0000000 --- a/iota-storage/src/util/outgoing_relay.rs +++ /dev/null @@ -1,61 +0,0 @@ -/* Accepts an outgoing relay only when its retry record and retention state commit together. */ -use crate::storage_error::StorageError; -use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay}; -use iota_util::route_target::RouteTarget; -use rusqlite::OptionalExtension; - -pub struct OutgoingRelay<'a> { - pub target: RouteTarget, - pub identity: &'a relay_queue::RelayIdentity, - pub frame: &'a [u8], - pub created_at: i64, - pub frame_id: u32, - pub type_map_version: &'a str, -} - -pub fn commit_outgoing_relay(relay: OutgoingRelay<'_>) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - relay_queue::enqueue_in_tx( - tx, - relay.target, - relay.identity, - relay.frame, - relay.created_at, - relay.frame_id, - relay.type_map_version, - )?; - if message_storage_policy::get_in_tx(tx, relay.identity.signer_id)?.history_mode - == message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery - { - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", rusqlite::params![relay.identity.signer_id, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?; - if let Some(message_id) = message_id { - chat_files::remove_message_history_in_tx(tx, relay.identity.signer_id, message_id)?; - } - } - relay_replay::mark_queued_in_tx(tx, relay.identity.signer_id, &relay.identity.message_id)?; - Ok(()) - }) -} - -pub fn apply_outgoing_history_policy( - storage_owner: i64, - relay_signer_id: i64, - relay_message_id: &str, -) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - if message_storage_policy::get_in_tx(tx, storage_owner)?.history_mode - != message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery - { - return Ok(()); - } - let message_id = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - rusqlite::params![storage_owner, relay_signer_id, relay_message_id], - |row| row.get::<_, i64>(0), - ).optional()?; - if let Some(message_id) = message_id { - chat_files::remove_message_history_in_tx(tx, storage_owner, message_id)?; - } - Ok(()) - }) -} diff --git a/iota-storage/src/util/receipt_policy.rs b/iota-storage/src/util/receipt_policy.rs deleted file mode 100644 index 934037e..0000000 --- a/iota-storage/src/util/receipt_policy.rs +++ /dev/null @@ -1,64 +0,0 @@ -/* Receipt disclosure is typed policy because Iota enforces it when relaying states. */ -use crate::storage_error::StorageError; -use crate::util::{db, sync}; -use rusqlite::OptionalExtension; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReceiptPolicy { - pub user_id: i64, - pub send_read_receipts: bool, - pub send_received_receipts: bool, - pub revision: i64, - pub updated_at: i64, -} -pub fn default_policy() -> ReceiptPolicy { - ReceiptPolicy { - user_id: 0, - send_read_receipts: true, - send_received_receipts: false, - revision: 0, - updated_at: 0, - } -} -fn validate(user_id: i64) -> Result<(), StorageError> { - if user_id > 0 { - Ok(()) - } else { - Err(StorageError::Other("invalid receipt policy owner".into())) - } -} -pub fn get(user_id: i64) -> Result { - validate(user_id)?; - db::with_db(|conn| { - let found = conn.query_row("SELECT send_read_receipts, send_received_receipts, revision, updated_at FROM user_receipt_policy WHERE user_id = ?1", [user_id], |r| Ok(ReceiptPolicy { user_id, send_read_receipts: r.get(0)?, send_received_receipts: r.get(1)?, revision: r.get(2)?, updated_at: r.get(3)? })).optional()?; - Ok(found.unwrap_or(ReceiptPolicy { - user_id, - ..default_policy() - })) - }) -} -pub fn set( - user_id: i64, - send_read_receipts: bool, - send_received_receipts: bool, -) -> Result { - validate(user_id)?; - db::with_immediate_transaction(|tx| { - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::ReceiptPolicy, - user_id, - sync::Operation::Upsert, - )?; - let updated_at = sync::now_millis(); - tx.execute("INSERT INTO user_receipt_policy (user_id, send_read_receipts, send_received_receipts, revision, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id) DO UPDATE SET send_read_receipts = excluded.send_read_receipts, send_received_receipts = excluded.send_received_receipts, revision = excluded.revision, updated_at = excluded.updated_at", rusqlite::params![user_id, send_read_receipts, send_received_receipts, revision, updated_at])?; - Ok(ReceiptPolicy { - user_id, - send_read_receipts, - send_received_receipts, - revision, - updated_at, - }) - }) -} diff --git a/iota-storage/src/util/relay_queue.rs b/iota-storage/src/util/relay_queue.rs deleted file mode 100644 index 75accbb..0000000 --- a/iota-storage/src/util/relay_queue.rs +++ /dev/null @@ -1,270 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use iota_util::route_target::RouteTarget; -use rusqlite::{OptionalExtension, Transaction, params}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingRelay { - pub id: i64, - pub target: RouteTarget, - pub frame: Vec, - pub created_at: i64, - pub frame_id: u32, - pub type_map_version: String, - pub relay_signer_id: Option, - pub relay_destination_user_id: Option, - pub relay_message_id: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RelayIdentity { - pub signer_id: i64, - pub destination_user_id: i64, - pub message_id: String, -} - -pub fn enqueue( - target: RouteTarget, - relay: &RelayIdentity, - frame: &[u8], - created_at: i64, - frame_id: u32, - type_map_version: &str, -) -> Result<(), StorageError> { - db::with_immediate_transaction(|tx| { - enqueue_in_tx( - tx, - target, - relay, - frame, - created_at, - frame_id, - type_map_version, - ) - }) -} - -pub fn enqueue_in_tx( - tx: &Transaction<'_>, - target: RouteTarget, - relay: &RelayIdentity, - frame: &[u8], - created_at: i64, - frame_id: u32, - type_map_version: &str, -) -> Result<(), StorageError> { - let destination_id = i64::try_from(target.id()) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - let target_kind = match target { - RouteTarget::User(_) => 0_i64, - RouteTarget::Iota(_) => 1_i64, - }; - tx.execute( - "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - destination_id, - target_kind, - relay.signer_id, - relay.destination_user_id, - relay.message_id, - frame, - created_at, - i64::from(frame_id), - type_map_version - ], - )?; - Ok(()) -} - -pub fn list(limit: i64) -> Result, StorageError> { - db::with_db(|connection| { - let mut statement = connection.prepare( - "SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays ORDER BY id LIMIT ?1", - )?; - let rows = statement.query_map(params![limit.clamp(1, 500)], |row| { - let destination_id = row.get::<_, i64>(1)?; - let target_kind = row.get::<_, i64>(2)?; - let destination_id = u64::try_from(destination_id).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 1, - rusqlite::types::Type::Integer, - "negative relay destination ID".into(), - ) - })?; - let target = match target_kind { - 0 => RouteTarget::User(destination_id), - 1 => RouteTarget::Iota(destination_id), - _ => { - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Integer, - "invalid relay target kind".into(), - )); - } - }; - Ok(PendingRelay { - id: row.get(0)?, - target, - frame: row.get(3)?, - created_at: row.get(4)?, - frame_id: u32::try_from(row.get::<_, i64>(5)?).map_err(|_| { - rusqlite::Error::FromSqlConversionFailure( - 5, - rusqlite::types::Type::Integer, - "negative relay frame ID".into(), - ) - })?, - type_map_version: row.get(6)?, - relay_signer_id: row.get(7)?, - relay_destination_user_id: row.get(8)?, - relay_message_id: row.get(9)?, - }) - })?; - rows.collect::, _>>().map_err(Into::into) - }) -} - -pub fn list_without_relay_identity() -> Result, StorageError> { - list_without_relay_identity_after(0, i64::MAX) -} - -pub fn list_without_relay_identity_after( - after_id: i64, - limit: i64, -) -> Result, StorageError> { - db::with_db(|connection| { - let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?; - let rows = statement.query_map( - params![after_id, limit.clamp(1, 500)], - pending_relay_from_row, - )?; - rows.collect::, _>>().map_err(Into::into) - }) -} - -fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result { - let destination_id = u64::try_from(row.get::<_, i64>(1)?) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, 0))?; - let target = match row.get::<_, i64>(2)? { - 0 => RouteTarget::User(destination_id), - 1 => RouteTarget::Iota(destination_id), - _ => return Err(rusqlite::Error::IntegralValueOutOfRange(2, 0)), - }; - Ok(PendingRelay { - id: row.get(0)?, - target, - frame: row.get(3)?, - created_at: row.get(4)?, - frame_id: u32::try_from(row.get::<_, i64>(5)?) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(5, 0))?, - type_map_version: row.get(6)?, - relay_signer_id: row.get(7)?, - relay_destination_user_id: row.get(8)?, - relay_message_id: row.get(9)?, - }) -} - -pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageError> { - db::with_db(|connection| { - connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4 WHERE id = ?1", params![id, relay.signer_id, relay.destination_user_id, relay.message_id])?; - Ok(()) - }) -} - -pub fn has_unclassified_relays() -> Result { - db::with_db(|connection| { - connection - .query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0)) - .map_err(Into::into) - }) -} - -pub fn find_user_delivery_in_tx( - tx: &Transaction<'_>, - destination_id: i64, - frame_id: u32, -) -> Result, StorageError> { - tx.query_row( - "SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", - params![destination_id, i64::from(frame_id)], - |row| { - let signer_id = row.get::<_, Option>(1)?; - let destination_user_id = row.get::<_, Option>(2)?; - let message_id = row.get::<_, Option>(3)?; - match (signer_id, destination_user_id, message_id) { - (Some(signer_id), Some(destination_user_id), Some(message_id)) => Ok((row.get(0)?, RelayIdentity { signer_id, destination_user_id, message_id })), - _ => Err(rusqlite::Error::InvalidQuery), - } - }, - ).optional().map_err(|error| match error { - rusqlite::Error::InvalidQuery => StorageError::PendingRelayOwnershipUnknown, - error => StorageError::Db(error), - }) -} - -pub fn acknowledge_in_tx(tx: &Transaction<'_>, id: i64) -> Result<(), StorageError> { - tx.execute("DELETE FROM pending_relays WHERE id = ?1", [id])?; - Ok(()) -} - -pub fn reject_outgoing_relay( - destination_iota: u64, - frame_id: u32, - signer_id: i64, - relay_message_id: &str, -) -> Result<(), StorageError> { - let destination_iota = i64::try_from(destination_iota) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_immediate_transaction(|tx| { - tx.execute("DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)])?; - tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, crate::util::sync::now_millis()])?; - Ok(()) - }) -} - -pub fn acknowledge(destination_id: u64, frame_id: u32) -> Result { - let destination_id = i64::try_from(destination_id) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_db(|connection| { - let changed = connection.execute( - "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", - params![destination_id, i64::from(frame_id)], - )?; - Ok(changed == 1) - }) -} - -pub fn acknowledge_iota(destination_id: u64, frame_id: u32) -> Result { - let destination_id = i64::try_from(destination_id) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_db(|connection| { - let changed = connection.execute( - "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", - params![destination_id, i64::from(frame_id)], - )?; - Ok(changed == 1) - }) -} - -pub fn remove_for_frame(target: RouteTarget, frame_id: u32) -> Result { - let destination_id = i64::try_from(target.id()) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - let target_kind = match target { - RouteTarget::User(_) => 0_i64, - RouteTarget::Iota(_) => 1_i64, - }; - db::with_db(|connection| { - let changed = connection.execute( - "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = ?2 AND frame_id = ?3", - params![destination_id, target_kind, i64::from(frame_id)], - )?; - Ok(changed == 1) - }) -} - -pub fn delete(id: i64) -> Result<(), StorageError> { - db::with_db(|connection| { - connection.execute("DELETE FROM pending_relays WHERE id = ?1", params![id])?; - Ok(()) - }) -} diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs deleted file mode 100644 index 0b7d03a..0000000 --- a/iota-storage/src/util/relay_replay.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use rusqlite::{OptionalExtension, params}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeliveredRelay { - pub signer_id: i64, - pub message_id: String, - pub destination_id: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RelayReservation { - New, - Existing { state: String, frame_matches: bool }, -} - -pub fn reserve( - signer_id: u64, - message_id: &str, - created_at: u64, - accepted_at: i64, - destination_id: u64, - frame: &[u8], - frame_id: u32, - type_map_version: &str, -) -> Result { - let signer_id = i64::try_from(signer_id) - .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; - let created_at = i64::try_from(created_at) - .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; - let destination_id = i64::try_from(destination_id) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - - db::with_db(|connection| { - let inserted = connection.execute( - "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'received')", - params![ - signer_id, - message_id, - created_at, - accepted_at, - destination_id, - frame, - i64::from(frame_id), - type_map_version - ], - )?; - if inserted == 1 { - return Ok(RelayReservation::New); - } - - let (state, existing_destination_id, existing_frame, existing_type_map_version): - (String, i64, Vec, String) = connection.query_row( - "SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_id = ?1 AND message_id = ?2", - params![signer_id, message_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get::<_, Vec>(2)?, - row.get(3)?, - )) - }, - )?; - Ok(RelayReservation::Existing { - state, - frame_matches: existing_destination_id == destination_id - && existing_frame == frame - && existing_type_map_version == type_map_version, - }) - }) -} - -pub fn mark_delivered_for_frame( - destination_id: u64, - frame_id: u32, -) -> Result, StorageError> { - let destination_id = i64::try_from(destination_id) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - db::with_immediate_transaction(|tx| { - let delivered_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let relay = tx - .query_row( - "SELECT signer_id, message_id, destination_id FROM relay_inbox WHERE destination_id = ?1 AND frame_id = ?2", - params![destination_id, i64::from(frame_id)], - |row| Ok(DeliveredRelay { signer_id: row.get(0)?, message_id: row.get(1)?, destination_id: row.get(2)? }), - ) - .optional()?; - if relay.is_none() { - return Ok(None); - } - tx.execute( - "UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE destination_id = ?1 AND frame_id = ?2", - params![destination_id, i64::from(frame_id), delivered_at], - )?; - Ok(relay) - }) -} - -pub fn mark_delivered_for_frame_in_tx( - tx: &rusqlite::Transaction<'_>, - destination_id: i64, - frame_id: u32, -) -> Result<(), StorageError> { - let changed = tx.execute( - "UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE destination_id = ?1 AND frame_id = ?2", - params![destination_id, i64::from(frame_id), crate::util::sync::now_millis()], - )?; - if changed == 0 { - return Err(StorageError::Other( - "pending relay has no relay inbox record".into(), - )); - } - Ok(()) -} - -fn mark_transition( - signer_id: u64, - message_id: &str, - state: &str, - column: &str, -) -> Result<(), StorageError> { - let signer_id = i64::try_from(signer_id) - .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - db::with_db(|connection| { - connection.execute( - &format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_id = ?1 AND message_id = ?2"), - params![signer_id, message_id, state, timestamp], - )?; - Ok(()) - }) -} - -pub fn mark_applied(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "applied", "applied_at") -} - -pub fn mark_queued(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "queued", "queued_at") -} - -pub fn mark_queued_in_tx( - tx: &rusqlite::Transaction<'_>, - signer_id: i64, - message_id: &str, -) -> Result<(), StorageError> { - tx.execute( - "UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", - params![signer_id, message_id, crate::util::sync::now_millis()], - )?; - Ok(()) -} - -pub fn mark_downstream_acked(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "delivered", "downstream_acked_at") -} - -pub fn mark_rejected(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "rejected", "rejected_at") -} - -pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> { - db::with_db(|connection| { - connection.execute( - "DELETE FROM relay_inbox WHERE COALESCE(downstream_acked_at, rejected_at) < ?1 AND state IN ('delivered', 'rejected')", - params![before_terminal_at], - )?; - connection.execute( - "DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id)", - [], - )?; - Ok(()) - }) -} - -pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result { - let signer_id = i64::try_from(signer_id) - .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; - let created_at = i64::try_from(created_at) - .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; - - db::with_db(|connection| { - let inserted = connection.execute( - "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", - params![signer_id, message_id, created_at], - )?; - Ok(inserted == 1) - }) -} - -#[cfg(test)] -mod tests { - use rusqlite::{Connection, params}; - - #[test] - fn replay_identity_uses_signer_and_message_id() -> Result<(), rusqlite::Error> { - let connection = Connection::open_in_memory()?; - connection.execute_batch( - "CREATE TABLE relay_replay (signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_id, message_id));", - )?; - - let first = connection.execute( - "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", - params![7_i64, "message", 1_i64], - )?; - let duplicate = connection.execute( - "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", - params![7_i64, "message", 2_i64], - )?; - let other_signer = connection.execute( - "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", - params![8_i64, "message", 2_i64], - )?; - - assert_eq!((first, duplicate, other_signer), (1, 0, 1)); - Ok(()) - } -} diff --git a/iota-storage/src/util/settings.rs b/iota-storage/src/util/settings.rs deleted file mode 100644 index 6fae52b..0000000 --- a/iota-storage/src/util/settings.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use iota_util::file_util::get_directory; -use rusqlite::{OptionalExtension, params}; -use std::fs; -use std::path::Path; - -pub const GLOBAL_SESSION_ID: i64 = 0; -const GLOBAL_SETTINGS_NAME: &str = "__global__"; - -pub fn save(user_id: i64, session_id: i64, name: &str, payload: &str) -> Result<(), StorageError> { - db::with_db(|conn| { - conn.execute( - "INSERT INTO settings (user_id, session_id, name, payload) VALUES (?1, ?2, ?3, ?4)\n ON CONFLICT(user_id, session_id, name) DO UPDATE SET payload = excluded.payload", - params![user_id, session_id, name, payload], - )?; - Ok(()) - }) -} - -pub fn load(user_id: i64, session_id: i64, name: &str) -> Result, StorageError> { - db::with_db(|conn| { - conn.query_row( - "SELECT payload FROM settings WHERE user_id = ?1 AND session_id = ?2 AND name = ?3", - params![user_id, session_id, name], - |row| row.get(0), - ) - .optional() - .map_err(StorageError::from) - }) -} - -pub fn list(user_id: i64, session_id: i64) -> Result, StorageError> { - db::with_db(|conn| { - let mut statement = conn.prepare( - "SELECT name FROM settings WHERE user_id = ?1 AND session_id = ?2 ORDER BY name", - )?; - let rows = statement.query_map(params![user_id, session_id], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(StorageError::from) - }) -} - -pub fn save_global(user_id: i64, payload: &str) -> Result<(), StorageError> { - save(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME, payload) -} - -pub fn load_global(user_id: i64) -> Result, StorageError> { - load(user_id, GLOBAL_SESSION_ID, GLOBAL_SETTINGS_NAME) -} - -pub fn migrate_legacy_files() -> Result<(), StorageError> { - let users_dir = Path::new(&get_directory()).join("users"); - let Ok(users) = fs::read_dir(users_dir) else { - return Ok(()); - }; - - for user_entry in users { - let user_entry = user_entry?; - let Ok(user_id) = user_entry.file_name().to_string_lossy().parse::() else { - continue; - }; - let user_dir = user_entry.path(); - - migrate_file_if_missing( - user_id, - GLOBAL_SESSION_ID, - GLOBAL_SETTINGS_NAME, - &user_dir.join("global.settings"), - )?; - - let settings_dir = user_dir.join("settings"); - let Ok(settings_entries) = fs::read_dir(settings_dir) else { - continue; - }; - for settings_entry in settings_entries { - let settings_entry = settings_entry?; - let path = settings_entry.path(); - if path.is_file() { - if let Some(name) = setting_name(&path) { - migrate_file_if_missing(user_id, GLOBAL_SESSION_ID, &name, &path)?; - } - continue; - } - - let Ok(session_id) = settings_entry.file_name().to_string_lossy().parse::() else { - continue; - }; - let Ok(device_settings) = fs::read_dir(path) else { - continue; - }; - for setting_entry in device_settings { - let setting_entry = setting_entry?; - let path = setting_entry.path(); - if let Some(name) = setting_name(&path) { - migrate_file_if_missing(user_id, session_id, &name, &path)?; - } - } - } - } - - Ok(()) -} - -fn setting_name(path: &Path) -> Option { - (path.extension()?.to_str()? == "settings").then(|| { - path.file_stem() - .and_then(|name| name.to_str()) - .unwrap_or_default() - .to_string() - }) -} - -fn migrate_file_if_missing( - user_id: i64, - session_id: i64, - name: &str, - path: &Path, -) -> Result<(), StorageError> { - if !path.is_file() || load(user_id, session_id, name)?.is_some() { - return Ok(()); - } - let payload = fs::read_to_string(path)?; - save(user_id, session_id, name, &payload) -} - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::Connection; - - #[test] - fn settings_schema_supports_user_and_session_keys() -> Result<(), StorageError> { - let conn = Connection::open_in_memory()?; - conn.execute_batch( - "CREATE TABLE settings ( - user_id INTEGER NOT NULL, - session_id INTEGER NOT NULL, - name TEXT NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (user_id, session_id, name) - );", - )?; - - conn.execute( - "INSERT INTO settings VALUES (?1, ?2, ?3, ?4)", - params![7, 11, "theme", "dark"], - )?; - conn.execute( - "INSERT INTO settings VALUES (?1, ?2, ?3, ?4)", - params![7, 12, "theme", "light"], - )?; - - let payload: String = conn.query_row( - "SELECT payload FROM settings WHERE user_id = 7 AND session_id = 11 AND name = 'theme'", - [], - |row| row.get(0), - )?; - assert_eq!(payload, "dark"); - - Ok(()) - } -} diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs deleted file mode 100644 index 78cb6d6..0000000 --- a/iota-storage/src/util/sync.rs +++ /dev/null @@ -1,425 +0,0 @@ -/* Durable per-user state journal used by device cache synchronization. */ -use crate::storage_error::StorageError; -use crate::util::db; -use rusqlite::{OptionalExtension, Transaction, params}; -use std::collections::BTreeMap; - -pub const CACHE_SCHEMA_VERSION: i64 = 4; -pub const STALE_CLIENT_SYNC_STATE_MS: i64 = 90 * 24 * 60 * 60 * 1_000; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EntityType { - Message, - Contact, - Setting, - UserBlob, - BlockedUser, - ReceiptPolicy, - MessageStoragePolicy, -} -impl EntityType { - fn as_str(self) -> &'static str { - match self { - Self::Message => "message", - Self::Contact => "contact", - Self::Setting => "setting", - Self::UserBlob => "user_blob", - Self::BlockedUser => "blocked_user", - Self::ReceiptPolicy => "receipt_policy", - Self::MessageStoragePolicy => "message_storage_policy", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Operation { - Upsert, - Delete, -} -impl Operation { - fn as_str(self) -> &'static str { - match self { - Self::Upsert => "upsert", - Self::Delete => "delete", - } - } -} - -#[derive(Debug, Default, Clone)] -pub struct Delta { - pub message_upserts: Vec, - pub deleted_message_ids: Vec, - pub contact_upserts: Vec, - pub deleted_contact_ids: Vec, - pub setting_upserts: Vec, - pub deleted_setting_ids: Vec, - pub blob_upserts: Vec, - pub deleted_blob_ids: Vec, - pub blocked_user_upserts: Vec, - pub deleted_blocked_user_ids: Vec, - pub receipt_policy_changed: bool, - pub message_storage_policy_changed: bool, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct SyncCompactionResult { - pub removed_stale_clients: usize, - pub removed_events: usize, - pub removed_blob_tombstones: usize, -} - -pub fn now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -pub fn record_event( - tx: &Transaction<'_>, - user_id: i64, - entity: EntityType, - entity_id: i64, - operation: Operation, -) -> Result { - tx.execute( - "INSERT INTO sync_heads (user_id, version) VALUES (?1, 0) ON CONFLICT(user_id) DO NOTHING", - [user_id], - )?; - let previous: i64 = tx.query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - )?; - let version = previous - .checked_add(1) - .ok_or_else(|| StorageError::Other("sync version overflow".into()))?; - tx.execute( - "UPDATE sync_heads SET version = ?2 WHERE user_id = ?1", - params![user_id, version], - )?; - tx.execute("INSERT INTO sync_events (user_id, version, entity_type, entity_id, operation, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user_id, version, entity.as_str(), entity_id, operation.as_str(), now_millis()])?; - Ok(version) -} - -pub fn head(user_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - ) - .unwrap_or(0)) - }) -} - -pub fn has_session(user_id: i64, session_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn - .query_row( - "SELECT 1 FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2", - params![user_id, session_id], - |_| Ok(()), - ) - .is_ok()) - }) -} - -pub fn acknowledged_version(user_id: i64, session_id: i64) -> Result, StorageError> { - db::with_db(|conn| { - conn.query_row( - "SELECT acknowledged_version FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2", - params![user_id, session_id], - |row| row.get(0), - ) - .optional() - .map_err(StorageError::from) - }) -} - -pub fn acknowledge( - user_id: i64, - session_id: i64, - version: i64, - cache_schema_version: i64, -) -> Result<(), StorageError> { - if user_id <= 0 || session_id <= 0 || version < 0 { - return Err(StorageError::Other("invalid sync acknowledgement".into())); - } - db::with_db(|conn| { - let head = conn - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - ) - .unwrap_or(0); - if version > head { - return Err(StorageError::Other( - "acknowledgement is ahead of head".into(), - )); - } - conn.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, now_millis()])?; - Ok(()) - }) -} - -/* Retain deltas until every non-stale client has acknowledged them. */ -pub fn compact_user_sync_state(user_id: i64) -> Result { - if user_id <= 0 { - return Err(StorageError::Other("invalid sync owner".into())); - } - let stale_before = now_millis().saturating_sub(STALE_CLIENT_SYNC_STATE_MS); - db::with_immediate_transaction(|tx| { - tx.execute( - "DELETE FROM client_message_deliveries WHERE user_id = ?1 AND session_id IN (SELECT session_id FROM client_sync_state WHERE user_id = ?1 AND updated_at < ?2)", - params![user_id, stale_before], - )?; - let removed_stale_clients = tx.execute( - "DELETE FROM client_sync_state WHERE user_id = ?1 AND updated_at < ?2", - params![user_id, stale_before], - )?; - let minimum_acknowledged: Option = tx.query_row( - "SELECT MIN(acknowledged_version) FROM client_sync_state WHERE user_id = ?1", - [user_id], - |row| row.get(0), - )?; - let revision = match minimum_acknowledged { - Some(revision) => revision, - None => tx - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |row| row.get(0), - ) - .unwrap_or(0), - }; - let removed_events = tx.execute( - "DELETE FROM sync_events WHERE user_id = ?1 AND version <= ?2", - params![user_id, revision], - )?; - let removed_blob_tombstones = tx.execute( - "DELETE FROM user_blobs WHERE user_id = ?1 AND deleted = 1 AND revision <= ?2", - params![user_id, revision], - )?; - Ok(SyncCompactionResult { - removed_stale_clients, - removed_events, - removed_blob_tombstones, - }) - }) -} - -pub fn compact_all_sync_state() -> Result { - let users = db::with_db(|conn| { - let mut statement = conn.prepare("SELECT user_id FROM sync_heads")?; - statement - .query_map([], |row| row.get::<_, i64>(0))? - .collect::, _>>() - .map_err(StorageError::from) - })?; - let mut total = SyncCompactionResult::default(); - for user_id in users { - let result = compact_user_sync_state(user_id)?; - total.removed_stale_clients += result.removed_stale_clients; - total.removed_events += result.removed_events; - total.removed_blob_tombstones += result.removed_blob_tombstones; - } - Ok(total) -} - -/// Returns the final operation for each entity after `from_version`. -pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result { - if from_version < 0 || from_version > captured_head { - return Err(StorageError::Other("invalid sync cursor".into())); - } - db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT entity_type, entity_id, operation FROM sync_events WHERE user_id = ?1 AND version > ?2 AND version <= ?3 ORDER BY version ASC")?; - let mut final_events = BTreeMap::<(String, i64), String>::new(); - for row in stmt.query_map(params![user_id, from_version, captured_head], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, String>(2)?, - )) - })? { - let (kind, id, operation) = row?; - final_events.insert((kind, id), operation); - } - Ok(reduce_events(final_events)) - }) -} - -fn reduce_events(events: BTreeMap<(String, i64), String>) -> Delta { - let mut out = Delta::default(); - for ((kind, id), operation) in events { - match (kind.as_str(), operation.as_str()) { - ("message", "delete") => out.deleted_message_ids.push(id), - ("message", _) => out.message_upserts.push(id), - ("contact", "delete") => out.deleted_contact_ids.push(id), - ("contact", _) => out.contact_upserts.push(id), - ("setting", "delete") => out.deleted_setting_ids.push(id), - ("setting", _) => out.setting_upserts.push(id), - ("user_blob", "delete") => out.deleted_blob_ids.push(id), - ("user_blob", _) => out.blob_upserts.push(id), - ("blocked_user", "delete") => out.deleted_blocked_user_ids.push(id), - ("blocked_user", _) => out.blocked_user_upserts.push(id), - ("receipt_policy", _) => out.receipt_policy_changed = true, - ("message_storage_policy", _) => out.message_storage_policy_changed = true, - _ => {} - } - } - out -} - -#[cfg(test)] -fn reduce_event_sequence(events: I) -> Delta -where - I: IntoIterator, -{ - let mut final_events = BTreeMap::new(); - for (kind, id, operation) in events { - final_events.insert((kind, id), operation); - } - reduce_events(final_events) -} - -#[cfg(test)] -mod tests { - use super::{EntityType, Operation, reduce_event_sequence, reduce_events}; - use rusqlite::Connection; - use std::collections::BTreeMap; - - #[test] - fn setting_upsert_is_included_in_delta() { - let mut events = BTreeMap::new(); - events.insert( - (EntityType::Setting.as_str().to_string(), 7), - Operation::Upsert.as_str().to_string(), - ); - - let delta = reduce_events(events); - - assert_eq!(delta.setting_upserts, vec![7]); - assert!(delta.deleted_setting_ids.is_empty()); - } - - #[test] - fn setting_delete_is_included_in_delta() { - let mut events = BTreeMap::new(); - events.insert( - (EntityType::Setting.as_str().to_string(), 7), - Operation::Delete.as_str().to_string(), - ); - - let delta = reduce_events(events); - - assert_eq!(delta.deleted_setting_ids, vec![7]); - assert!(delta.setting_upserts.is_empty()); - } - - #[test] - fn blob_delete_is_included_in_delta() { - let mut events = BTreeMap::new(); - events.insert( - (EntityType::UserBlob.as_str().to_string(), 7), - Operation::Delete.as_str().to_string(), - ); - - let delta = reduce_events(events); - - assert_eq!(delta.deleted_blob_ids, vec![7]); - assert!(delta.blob_upserts.is_empty()); - } - - #[test] - fn final_setting_operation_wins() { - let mut events = BTreeMap::new(); - events.insert( - (EntityType::Setting.as_str().to_string(), 7), - Operation::Upsert.as_str().to_string(), - ); - events.insert( - (EntityType::Setting.as_str().to_string(), 8), - Operation::Delete.as_str().to_string(), - ); - - let delta = reduce_events(events); - - assert_eq!(delta.setting_upserts, vec![7]); - assert_eq!(delta.deleted_setting_ids, vec![8]); - } - - #[test] - fn setting_upsert_then_delete_resolves_to_delete() { - let delta = reduce_event_sequence([ - ( - EntityType::Setting.as_str().to_string(), - 7, - Operation::Upsert.as_str().to_string(), - ), - ( - EntityType::Setting.as_str().to_string(), - 7, - Operation::Delete.as_str().to_string(), - ), - ]); - - assert_eq!(delta.deleted_setting_ids, vec![7]); - assert!(delta.setting_upserts.is_empty()); - } - - #[test] - fn setting_delete_then_upsert_resolves_to_upsert() { - let delta = reduce_event_sequence([ - ( - EntityType::Setting.as_str().to_string(), - 7, - Operation::Delete.as_str().to_string(), - ), - ( - EntityType::Setting.as_str().to_string(), - 7, - Operation::Upsert.as_str().to_string(), - ), - ]); - - assert_eq!(delta.setting_upserts, vec![7]); - assert!(delta.deleted_setting_ids.is_empty()); - } - - #[test] - fn setting_events_share_the_user_sync_head() { - let connection = Connection::open_in_memory().unwrap(); - connection - .execute_batch( - " - CREATE TABLE sync_heads (user_id INTEGER PRIMARY KEY, version INTEGER NOT NULL); - CREATE TABLE sync_events ( - user_id INTEGER NOT NULL, - version INTEGER NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - operation TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, version) - ); - ", - ) - .unwrap(); - let transaction = connection.unchecked_transaction().unwrap(); - let message = - super::record_event(&transaction, 1, EntityType::Message, 10, Operation::Upsert) - .unwrap(); - let setting = - super::record_event(&transaction, 1, EntityType::Setting, 11, Operation::Upsert) - .unwrap(); - let contact = - super::record_event(&transaction, 1, EntityType::Contact, 12, Operation::Upsert) - .unwrap(); - transaction.commit().unwrap(); - - assert_eq!((message, setting, contact), (1, 2, 3)); - } -} diff --git a/iota-storage/src/util/synced_settings.rs b/iota-storage/src/util/synced_settings.rs deleted file mode 100644 index ef7c574..0000000 --- a/iota-storage/src/util/synced_settings.rs +++ /dev/null @@ -1,661 +0,0 @@ -use crate::storage_error::StorageError; -use crate::util::db; -use crate::util::sync::{self, EntityType, Operation}; -use rusqlite::{Connection, OptionalExtension, Row, Transaction, params}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingScope { - User, - Contact, - Community, -} - -impl SettingScope { - pub fn as_str(self) -> &'static str { - match self { - Self::User => "user", - Self::Contact => "contact", - Self::Community => "community", - } - } - - pub fn parse(value: &str) -> Option { - match value { - "user" => Some(Self::User), - "contact" => Some(Self::Contact), - "community" => Some(Self::Community), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SyncedSetting { - pub id: i64, - pub user_id: i64, - pub scope: SettingScope, - pub scope_key: String, - pub name: String, - pub payload: String, - pub revision: i64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DeletedSetting { - pub id: i64, - pub revision: i64, - pub changed: bool, -} - -pub fn is_valid_name(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|character| character.is_alphanumeric() || "_-.".contains(character)) - && !name.contains("..") -} - -fn validate_locator(scope: SettingScope, scope_key: &str, name: &str) -> Result<(), StorageError> { - if !is_valid_name(name) { - return Err(StorageError::Other( - "invalid synchronized setting name".into(), - )); - } - - match scope { - SettingScope::User if !scope_key.is_empty() => Err(StorageError::Other( - "user settings must not have a target".into(), - )), - SettingScope::Contact => { - let valid_contact = scope_key.parse::().is_ok_and(|id| id > 0); - if valid_contact { - Ok(()) - } else { - Err(StorageError::Other("invalid contact setting target".into())) - } - } - SettingScope::Community if scope_key.is_empty() => Err(StorageError::Other( - "community settings require a target".into(), - )), - _ => Ok(()), - } -} - -fn normalized_scope_key(scope: SettingScope, scope_key: &str) -> Result { - match scope { - SettingScope::Contact => scope_key - .parse::() - .map(|id| id.to_string()) - .map_err(|_| StorageError::Other("invalid contact setting target".into())), - SettingScope::User | SettingScope::Community => Ok(scope_key.to_string()), - } -} - -fn setting_from_parts( - id: i64, - user_id: i64, - scope_type: String, - scope_key: String, - name: String, - payload: String, - revision: i64, -) -> Result { - let scope = SettingScope::parse(&scope_type) - .ok_or_else(|| StorageError::Other("database contains an invalid setting scope".into()))?; - Ok(SyncedSetting { - id, - user_id, - scope, - scope_key, - name, - payload, - revision, - }) -} - -fn row_parts(row: &Row<'_>) -> rusqlite::Result<(i64, i64, String, String, String, String, i64)> { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - )) -} - -fn load_setting_from_tx( - tx: &Transaction<'_>, - setting_id: i64, -) -> Result { - let parts = tx.query_row( - "SELECT id, user_id, scope_type, scope_key, name, payload, revision - FROM synced_settings - WHERE id = ?1 AND deleted = 0", - [setting_id], - row_parts, - )?; - setting_from_parts( - parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, - ) -} - -pub fn set( - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, - payload: &str, -) -> Result { - if user_id <= 0 { - return Err(StorageError::Other("invalid setting owner".into())); - } - validate_locator(scope, scope_key, name)?; - let scope_key = normalized_scope_key(scope, scope_key)?; - - db::with_immediate_transaction(|tx| set_in_tx(tx, user_id, scope, &scope_key, name, payload)) -} - -fn set_in_tx( - tx: &Transaction<'_>, - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, - payload: &str, -) -> Result { - tx.execute( - "INSERT INTO synced_settings - (user_id, scope_type, scope_key, name, payload, revision, deleted) - VALUES (?1, ?2, ?3, ?4, ?5, 0, 0) - ON CONFLICT(user_id, scope_type, scope_key, name) DO NOTHING", - params![user_id, scope.as_str(), scope_key, name, payload], - )?; - let setting_id: i64 = tx.query_row( - "SELECT id FROM synced_settings - WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND name = ?4", - params![user_id, scope.as_str(), scope_key, name], - |row| row.get(0), - )?; - let revision = sync::record_event( - tx, - user_id, - EntityType::Setting, - setting_id, - Operation::Upsert, - )?; - tx.execute( - "UPDATE synced_settings - SET payload = ?2, deleted = 0, revision = ?3 - WHERE id = ?1", - params![setting_id, payload, revision], - )?; - load_setting_from_tx(tx, setting_id) -} - -pub fn get( - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, -) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid setting owner".into())); - } - validate_locator(scope, scope_key, name)?; - let scope_key = normalized_scope_key(scope, scope_key)?; - db::with_db(|conn| get_from_connection(conn, user_id, scope, &scope_key, name)) -} - -fn get_from_connection( - conn: &Connection, - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, -) -> Result, StorageError> { - let parts = conn - .query_row( - "SELECT id, user_id, scope_type, scope_key, name, payload, revision - FROM synced_settings - WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 - AND name = ?4 AND deleted = 0", - params![user_id, scope.as_str(), scope_key, name], - row_parts, - ) - .optional()?; - parts - .map(|parts| { - setting_from_parts( - parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, - ) - }) - .transpose() -} - -pub fn list(user_id: i64) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid setting owner".into())); - } - db::with_db(|conn| list_from_connection(conn, user_id)) -} - -fn list_from_connection( - conn: &Connection, - user_id: i64, -) -> Result, StorageError> { - let mut statement = conn.prepare( - "SELECT id, user_id, scope_type, scope_key, name, payload, revision - FROM synced_settings - WHERE user_id = ?1 AND deleted = 0 - ORDER BY id ASC", - )?; - let rows = statement.query_map([user_id], row_parts)?; - let mut settings = Vec::new(); - for row in rows { - let parts = row?; - settings.push(setting_from_parts( - parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, - )?); - } - Ok(settings) -} - -pub fn list_by_ids(user_id: i64, ids: &[i64]) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid setting owner".into())); - } - if ids.is_empty() { - return Ok(Vec::new()); - } - db::with_db(|conn| list_by_ids_from_connection(conn, user_id, ids)) -} - -fn list_by_ids_from_connection( - conn: &Connection, - user_id: i64, - ids: &[i64], -) -> Result, StorageError> { - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(", "); - let query = format!( - "SELECT id, user_id, scope_type, scope_key, name, payload, revision - FROM synced_settings - WHERE user_id = ? AND deleted = 0 AND id IN ({placeholders}) - ORDER BY id ASC" - ); - let mut values = Vec::with_capacity(ids.len() + 1); - values.push(user_id); - values.extend_from_slice(ids); - let mut statement = conn.prepare(&query)?; - let rows = statement.query_map(rusqlite::params_from_iter(values), row_parts)?; - let mut settings = Vec::new(); - for row in rows { - let parts = row?; - settings.push(setting_from_parts( - parts.0, parts.1, parts.2, parts.3, parts.4, parts.5, parts.6, - )?); - } - Ok(settings) -} - -pub fn delete( - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, -) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid setting owner".into())); - } - validate_locator(scope, scope_key, name)?; - let scope_key = normalized_scope_key(scope, scope_key)?; - db::with_immediate_transaction(|tx| delete_in_tx(tx, user_id, scope, &scope_key, name)) -} - -fn delete_in_tx( - tx: &Transaction<'_>, - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, -) -> Result, StorageError> { - let existing = tx - .query_row( - "SELECT id, revision, deleted FROM synced_settings - WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND name = ?4", - params![user_id, scope.as_str(), scope_key, name], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - )) - }, - ) - .optional()?; - let Some((setting_id, current_revision, deleted)) = existing else { - return Ok(None); - }; - if deleted != 0 { - return Ok(Some(DeletedSetting { - id: setting_id, - revision: current_revision, - changed: false, - })); - } - - let revision = sync::record_event( - tx, - user_id, - EntityType::Setting, - setting_id, - Operation::Delete, - )?; - tx.execute( - "UPDATE synced_settings SET deleted = 1, revision = ?2 WHERE id = ?1", - params![setting_id, revision], - )?; - Ok(Some(DeletedSetting { - id: setting_id, - revision, - changed: true, - })) -} - -pub(crate) fn delete_scope_in_tx( - tx: &Transaction<'_>, - user_id: i64, - scope: SettingScope, - scope_key: &str, -) -> Result<(), StorageError> { - let setting_ids = { - let mut statement = tx.prepare( - "SELECT id FROM synced_settings - WHERE user_id = ?1 AND scope_type = ?2 AND scope_key = ?3 AND deleted = 0", - )?; - let rows = statement.query_map(params![user_id, scope.as_str(), scope_key], |row| { - row.get::<_, i64>(0) - })?; - rows.collect::, _>>()? - }; - - for setting_id in setting_ids { - let revision = sync::record_event( - tx, - user_id, - EntityType::Setting, - setting_id, - Operation::Delete, - )?; - tx.execute( - "UPDATE synced_settings SET deleted = 1, revision = ?2 WHERE id = ?1", - params![setting_id, revision], - )?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - SettingScope, delete_in_tx, get_from_connection, is_valid_name, - list_by_ids_from_connection, list_from_connection, set_in_tx, - }; - use rusqlite::Connection; - - fn connection() -> Connection { - let connection = Connection::open_in_memory().unwrap(); - connection - .execute_batch( - " - CREATE TABLE sync_heads ( - user_id INTEGER PRIMARY KEY, - version INTEGER NOT NULL - ); - CREATE TABLE sync_events ( - user_id INTEGER NOT NULL, - version INTEGER NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - operation TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, version) - ); - CREATE TABLE synced_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - scope_type TEXT NOT NULL, - scope_key TEXT NOT NULL, - name TEXT NOT NULL, - payload TEXT NOT NULL, - revision INTEGER NOT NULL, - deleted INTEGER NOT NULL DEFAULT 0, - UNIQUE(user_id, scope_type, scope_key, name) - ); - ", - ) - .unwrap(); - connection - } - - fn set( - connection: &mut Connection, - user_id: i64, - scope: SettingScope, - scope_key: &str, - name: &str, - payload: &str, - ) -> super::SyncedSetting { - let transaction = connection.transaction().unwrap(); - let setting = set_in_tx(&transaction, user_id, scope, scope_key, name, payload).unwrap(); - transaction.commit().unwrap(); - setting - } - - #[test] - fn parses_supported_setting_scopes() { - assert_eq!(SettingScope::parse("user"), Some(SettingScope::User)); - assert_eq!(SettingScope::parse("contact"), Some(SettingScope::Contact)); - assert_eq!( - SettingScope::parse("community"), - Some(SettingScope::Community) - ); - } - - #[test] - fn rejects_unknown_setting_scope() { - assert_eq!(SettingScope::parse("device"), None); - } - - #[test] - fn validates_setting_name_syntax() { - assert!(is_valid_name("notifications.enabled")); - assert!(!is_valid_name("notifications..enabled")); - assert!(!is_valid_name("")); - assert!(!is_valid_name("notifications/enabled")); - } - - #[test] - fn stores_and_loads_a_user_setting() { - let mut connection = connection(); - let stored = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "true", - ); - let loaded = get_from_connection( - &connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - ) - .unwrap(); - - assert_eq!(loaded, Some(stored)); - } - - #[test] - fn contact_and_community_targets_are_distinct() { - let mut connection = connection(); - set( - &mut connection, - 1, - SettingScope::Contact, - "123", - "notifications.enabled", - "false", - ); - set( - &mut connection, - 1, - SettingScope::Community, - "community-a", - "notifications.enabled", - "true", - ); - - assert_eq!(list_from_connection(&connection, 1).unwrap().len(), 2); - } - - #[test] - fn users_store_same_setting_independently() { - let mut connection = connection(); - set( - &mut connection, - 1, - SettingScope::User, - "", - "receipts.user_read", - "true", - ); - set( - &mut connection, - 2, - SettingScope::User, - "", - "receipts.user_read", - "false", - ); - - assert_eq!( - get_from_connection(&connection, 1, SettingScope::User, "", "receipts.user_read") - .unwrap() - .unwrap() - .payload, - "true" - ); - assert_eq!( - get_from_connection(&connection, 2, SettingScope::User, "", "receipts.user_read") - .unwrap() - .unwrap() - .payload, - "false" - ); - } - - #[test] - fn update_retains_id_and_advances_revision() { - let mut connection = connection(); - let first = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "true", - ); - let second = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "false", - ); - - assert_eq!(second.id, first.id); - assert!(second.revision > first.revision); - } - - #[test] - fn delete_tombstones_setting_and_records_delta_delete() { - let mut connection = connection(); - let stored = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "true", - ); - let transaction = connection.transaction().unwrap(); - let deleted = delete_in_tx( - &transaction, - 1, - SettingScope::User, - "", - "notifications.enabled", - ) - .unwrap() - .unwrap(); - transaction.commit().unwrap(); - let journal_operation: String = connection - .query_row( - "SELECT operation FROM sync_events WHERE entity_id = ?1 ORDER BY version DESC LIMIT 1", - [stored.id], - |row| row.get(0), - ) - .unwrap(); - - assert_eq!(deleted.id, stored.id); - assert!(deleted.changed); - assert_eq!(journal_operation, "delete"); - assert!(list_from_connection(&connection, 1).unwrap().is_empty()); - assert!( - list_by_ids_from_connection(&connection, 1, &[stored.id]) - .unwrap() - .is_empty() - ); - } - - #[test] - fn setting_can_be_recreated_with_the_same_id() { - let mut connection = connection(); - let first = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "true", - ); - let transaction = connection.transaction().unwrap(); - delete_in_tx( - &transaction, - 1, - SettingScope::User, - "", - "notifications.enabled", - ) - .unwrap(); - transaction.commit().unwrap(); - let recreated = set( - &mut connection, - 1, - SettingScope::User, - "", - "notifications.enabled", - "false", - ); - - assert_eq!(recreated.id, first.id); - assert_eq!(recreated.payload, "false"); - } -} diff --git a/iota-storage/src/util/user_blobs.rs b/iota-storage/src/util/user_blobs.rs deleted file mode 100644 index 34d1cb3..0000000 --- a/iota-storage/src/util/user_blobs.rs +++ /dev/null @@ -1,256 +0,0 @@ -/* Opaque client-owned data is stored without interpreting its encrypted bytes. */ -use crate::storage_error::StorageError; -use crate::util::{db, sync}; -use rusqlite::{Connection, OptionalExtension, Row, Transaction, params}; - -pub const MAX_BLOB_ID_BYTES: usize = 256; -pub const MAX_BLOB_BYTES: usize = 1_048_576; -pub const MAX_USER_BLOB_BYTES: usize = 16_777_216; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UserBlob { - pub id: i64, - pub user_id: i64, - pub blob_id: String, - pub blob: Vec, - pub revision: i64, - pub updated_at: i64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeletedUserBlob { - pub id: i64, - pub blob_id: String, - pub revision: i64, - pub changed: bool, -} - -fn validate(user_id: i64, blob_id: &str, blob: Option<&[u8]>) -> Result<(), StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid blob owner".into())); - } - if blob_id.is_empty() || blob_id.len() > MAX_BLOB_ID_BYTES { - return Err(StorageError::Other("invalid blob id".into())); - } - if let Some(blob) = blob { - if blob.len() > MAX_BLOB_BYTES { - return Err(StorageError::Other("blob exceeds size limit".into())); - } - } - Ok(()) -} - -fn from_row(row: &Row<'_>) -> rusqlite::Result { - Ok(UserBlob { - id: row.get(0)?, - user_id: row.get(1)?, - blob_id: row.get(2)?, - blob: row.get(3)?, - revision: row.get(4)?, - updated_at: row.get(5)?, - }) -} - -fn load(tx: &Transaction<'_>, id: i64) -> Result { - Ok(tx.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE id = ?1 AND deleted = 0", [id], from_row)?) -} - -pub fn put( - user_id: i64, - blob_id: &str, - blob: &[u8], - expected_revision: Option, -) -> Result { - validate(user_id, blob_id, Some(blob))?; - db::with_immediate_transaction(|tx| { - let existing = tx - .query_row( - "SELECT id, revision, deleted, length(blob) FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2", - params![user_id, blob_id], - |r| { - Ok(( - r.get::<_, i64>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, bool>(2)?, - r.get::<_, i64>(3)?, - )) - }, - ) - .optional()?; - let existing_id = if let Some((id, revision, deleted, _)) = existing { - if deleted { - if !matches!(expected_revision, Some(0)) { - return Err(StorageError::RevisionConflict); - } - } else if expected_revision != Some(revision) { - return Err(StorageError::RevisionConflict); - } - Some(id) - } else { - if !matches!(expected_revision, None | Some(0)) { - return Err(StorageError::RevisionConflict); - } - None - }; - let current_size = existing - .filter(|(_, _, deleted, _)| !*deleted) - .map(|(_, _, _, size)| size) - .unwrap_or(0); - let aggregate: i64 = tx.query_row("SELECT COALESCE(SUM(length(blob)), 0) FROM user_blobs WHERE user_id = ?1 AND deleted = 0", [user_id], |r| r.get(0))?; - let proposed_size = aggregate - .checked_sub(current_size) - .and_then(|size| size.checked_add(blob.len() as i64)) - .ok_or_else(|| StorageError::Other("user blob storage quota overflow".into()))?; - if proposed_size > MAX_USER_BLOB_BYTES as i64 { - return Err(StorageError::Other( - "user blob storage limit exceeded".into(), - )); - } - let id = match existing_id { - Some(id) => id, - None => { - tx.execute("INSERT INTO user_blobs (user_id, blob_id, blob, revision, deleted, updated_at) VALUES (?1, ?2, ?3, 0, 0, ?4)", params![user_id, blob_id, blob, sync::now_millis()])?; - tx.last_insert_rowid() - } - }; - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::UserBlob, - id, - sync::Operation::Upsert, - )?; - tx.execute("UPDATE user_blobs SET blob = ?2, revision = ?3, deleted = 0, updated_at = ?4 WHERE id = ?1", params![id, blob, revision, sync::now_millis()])?; - load(tx, id) - }) -} - -pub fn get(user_id: i64, blob_id: &str) -> Result, StorageError> { - validate(user_id, blob_id, None)?; - db::with_db(|conn| get_on(conn, user_id, blob_id)) -} -fn get_on( - conn: &Connection, - user_id: i64, - blob_id: &str, -) -> Result, StorageError> { - Ok(conn.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2 AND deleted = 0", params![user_id, blob_id], from_row).optional()?) -} -pub fn list(user_id: i64) -> Result, StorageError> { - list_by_query( - user_id, - "SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND deleted = 0", - &[], - ) -} -pub fn list_by_ids(user_id: i64, ids: &[i64]) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid blob owner".into())); - } - if ids.is_empty() { - return Ok(Vec::new()); - } - db::with_db(|conn| { - let mut out = Vec::new(); - for id in ids { - if let Some(blob) = conn.query_row("SELECT id, user_id, blob_id, blob, revision, updated_at FROM user_blobs WHERE user_id = ?1 AND id = ?2 AND deleted = 0", params![user_id, id], from_row).optional()? { out.push(blob); } - } - Ok(out) - }) -} - -pub fn list_deleted_by_ids( - user_id: i64, - ids: &[i64], -) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid blob owner".into())); - } - if ids.is_empty() { - return Ok(Vec::new()); - } - db::with_db(|conn| { - let mut deleted = Vec::new(); - for id in ids { - if let Some(blob) = conn.query_row( - "SELECT id, blob_id, revision FROM user_blobs WHERE user_id = ?1 AND id = ?2 AND deleted = 1", - params![user_id, id], - |row| { - Ok(DeletedUserBlob { - id: row.get(0)?, - blob_id: row.get(1)?, - revision: row.get(2)?, - changed: true, - }) - }, - ).optional()? { - deleted.push(blob); - } - } - Ok(deleted) - }) -} -fn list_by_query(user_id: i64, query: &str, _: &[i64]) -> Result, StorageError> { - if user_id <= 0 { - return Err(StorageError::Other("invalid blob owner".into())); - } - db::with_db(|conn| { - let mut statement = conn.prepare(query)?; - let rows = statement.query_map([user_id], from_row)?; - rows.collect::, _>>() - .map_err(StorageError::from) - }) -} -pub fn delete( - user_id: i64, - blob_id: &str, - expected_revision: Option, -) -> Result, StorageError> { - validate(user_id, blob_id, None)?; - db::with_immediate_transaction(|tx| { - let existing = tx - .query_row( - "SELECT id, revision, deleted FROM user_blobs WHERE user_id = ?1 AND blob_id = ?2", - params![user_id, blob_id], - |r| { - Ok(( - r.get::<_, i64>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, bool>(2)?, - )) - }, - ) - .optional()?; - let Some((id, current, deleted)) = existing else { - return Ok(None); - }; - if deleted { - return Ok(Some(DeletedUserBlob { - id, - blob_id: blob_id.into(), - revision: current, - changed: false, - })); - } - if expected_revision != Some(current) { - return Err(StorageError::RevisionConflict); - } - let revision = sync::record_event( - tx, - user_id, - sync::EntityType::UserBlob, - id, - sync::Operation::Delete, - )?; - tx.execute( - "UPDATE user_blobs SET blob = X'', deleted = 1, revision = ?2, updated_at = ?3 WHERE id = ?1", - params![id, revision, sync::now_millis()], - )?; - Ok(Some(DeletedUserBlob { - id, - blob_id: blob_id.into(), - revision, - changed: true, - })) - }) -} diff --git a/iota-terms/Cargo.toml b/iota-terms/Cargo.toml deleted file mode 100644 index c53c3cf..0000000 --- a/iota-terms/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "iota-terms" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-util = { path = "../iota-util" } - -reqwest = "0.13.2" -tokio = { version = "1.50.0", features = ["macros"] } diff --git a/iota-terms/src/consent.rs b/iota-terms/src/consent.rs deleted file mode 100644 index 601ab1f..0000000 --- a/iota-terms/src/consent.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Durable, deployment-scoped consent records. -//! -//! This deliberately contains no UI code. Both the terminal client and the -//! daemon use the same record so a UI-local decision can never start services. - -use crate::{Doc, TermsType as Type}; -use std::fs; -use std::io; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -const FILE_NAME: &str = "terms-consent-v1"; - -#[derive(Clone, Debug, Default)] -pub struct ConsentRecord { - pub eula: Option<(String, String)>, - pub tos: Option<(String, String)>, - pub privacy: Option<(String, String)>, -} - -impl ConsentRecord { - pub fn has_all_required(&self) -> bool { - self.eula.is_some() && self.tos.is_some() && self.privacy.is_some() - } - - pub fn accepts(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool { - matches_doc(&self.eula, eula) - && matches_doc(&self.tos, tos) - && matches_doc(&self.privacy, privacy) - } - - pub fn accept(&mut self, doc: &Doc) { - let value = Some((doc.get_version(), doc.get_hash())); - match doc.doc_type { - Type::EULA => self.eula = value, - Type::TOS => self.tos = value, - Type::PP => self.privacy = value, - } - } -} - -fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool { - matches!(value, Some((_, hash)) if hash == &doc.get_hash()) -} - -pub fn load(state_dir: &Path) -> ConsentRecord { - let Ok(text) = fs::read_to_string(state_dir.join(FILE_NAME)) else { - return ConsentRecord::default(); - }; - let mut record = ConsentRecord::default(); - for line in text.lines() { - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let Some((version, hash)) = value.split_once(':') else { - continue; - }; - let value = Some((version.to_owned(), hash.to_owned())); - match key { - "eula" => record.eula = value, - "tos" => record.tos = value, - "privacy" => record.privacy = value, - _ => {} - } - } - record -} - -pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> { - fs::create_dir_all(state_dir)?; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut text = format!("# Iota terms consent record; accepted_at_unix={timestamp}\n"); - for (name, value) in [ - ("eula", &record.eula), - ("tos", &record.tos), - ("privacy", &record.privacy), - ] { - if let Some((version, hash)) = value { - text.push_str(&format!("{name}={version}:{hash}\n")); - } - } - iota_util::atomic_file::replace(&state_dir.join(FILE_NAME), text.as_bytes(), 3) -} diff --git a/iota-terms/src/doc.rs b/iota-terms/src/doc.rs deleted file mode 100644 index 7510407..0000000 --- a/iota-terms/src/doc.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::terms_getter::Type; -use iota_util::crypto_helper::hex_hash; - -#[derive(Clone, Debug, PartialEq, Eq)] -#[allow(unused)] -pub struct Doc { - version: String, - hash: String, - pub doc_type: Type, - timestamp: u64, -} - -#[allow(dead_code)] -impl Doc { - pub fn new(version: String, hash: String, doc_type: Type, timestamp: u64) -> Doc { - Doc { - version, - hash, - doc_type, - timestamp, - } - } - - pub fn from_raw(doc_type: Type, content: String, timestamp: u64) -> Doc { - Doc::new( - timestamp.to_string(), - hex_hash(&content), - doc_type, - timestamp, - ) - } - - pub fn equals_some(&self, other: &Option) -> bool { - if let Some(other) = other { - self.equals(other) - } else { - false - } - } - pub fn equals(&self, other: &Self) -> bool { - self.get_hash() == other.get_hash() - } - - pub fn get_version(&self) -> String { - self.version.clone() - } - pub fn get_hash(&self) -> String { - self.hash.clone() - } - pub fn get_time(&self) -> u64 { - self.timestamp - } - #[cfg(test)] - fn timestamp(&self) -> u64 { - self.timestamp - } -} - -#[cfg(test)] -mod tests { - use super::Doc; - use crate::terms_getter::Type; - use iota_util::crypto_helper::hex_hash; - - #[test] - fn raw_documents_use_a_local_timestamp_and_content_hash() { - let content = "# EULA\n".to_owned(); - let document = Doc::from_raw(Type::EULA, content.clone(), 123); - - assert_eq!(document.doc_type, Type::EULA); - assert_eq!(document.get_version(), "123"); - assert_eq!(document.timestamp(), 123); - assert_eq!(document.get_hash(), hex_hash(&content)); - } - - #[test] - fn matching_documents_ignore_the_fetch_timestamp() { - let earlier = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 123); - let later = Doc::from_raw(Type::EULA, "# EULA\n".to_owned(), 456); - - assert!(earlier.equals(&later)); - } -} diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs deleted file mode 100644 index 58e6de0..0000000 --- a/iota-terms/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod consent; -pub mod terms_getter; - -pub use terms_getter::Type as TermsType; -pub use terms_getter::get_current_docs; -pub use terms_getter::get_link; -// pub use terms_getter::get_newest_docs; -pub use terms_getter::get_newest_link; -pub use terms_getter::get_terms; - -pub mod doc; - -pub use doc::Doc; diff --git a/iota-terms/src/terms_getter.rs b/iota-terms/src/terms_getter.rs deleted file mode 100755 index 9ccfffa..0000000 --- a/iota-terms/src/terms_getter.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::doc::Doc; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Type { - EULA, - TOS, - PP, -} - -impl Type { - pub fn to_str(&self) -> &str { - match self { - Self::EULA => "eula", - Self::TOS => "terms-of-service", - Self::PP => "privacy-policy", - } - } - pub fn to_string(&self) -> String { - match self { - Self::EULA => "End User License Agreement".to_string(), - Self::TOS => "Terms of Service".to_string(), - Self::PP => "Privacy Policy".to_string(), - } - } -} - -pub fn get_link(terms_type: Type) -> String { - format!( - "https://legal.methanium.net/tensamin/{}", - terms_type.to_str() - ) -} - -/* - * The legal service exposes only its latest raw documents. Keep this helper - * for the dormant pre-emptive-acceptance UI until it has a source of future - * document versions again. - */ -pub fn get_newest_link(terms_type: Type) -> String { - get_link(terms_type) -} - -pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); - let (eula, tos, privacy) = tokio::join!( - get_terms(Type::EULA), - get_terms(Type::TOS), - get_terms(Type::PP), - ); - - Some(( - Doc::from_raw(Type::EULA, eula?, timestamp), - Doc::from_raw(Type::TOS, tos?, timestamp), - Doc::from_raw(Type::PP, privacy?, timestamp), - )) -} - -/* - * Future documents are unavailable from the raw endpoint. Restore this API - * with the pre-emptive-acceptance flow when the service provides them again. - * -pub async fn get_newest_docs() -> Option<(Doc, Doc, Doc)> { - None -} -*/ - -pub async fn get_terms(terms_type: Type) -> Option { - reqwest::get(format!( - "https://legal.methanium.net/tensamin/{}/raw", - terms_type.to_str() - )) - .await - .ok()? - .text() - .await - .ok() -} - -#[cfg(test)] -mod tests { - use super::{Type, get_link}; - - #[test] - fn maps_document_types_to_tensamin_raw_document_names() { - assert_eq!(Type::EULA.to_str(), "eula"); - assert_eq!(Type::TOS.to_str(), "terms-of-service"); - assert_eq!(Type::PP.to_str(), "privacy-policy"); - } - - #[test] - fn links_to_the_tensamin_document_page() { - assert_eq!( - get_link(Type::TOS), - "https://legal.methanium.net/tensamin/terms-of-service" - ); - } -} diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml deleted file mode 100644 index 1fbbef9..0000000 --- a/iota-updater/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "iota-updater" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-paths = { path = "../iota-paths" } -tokio = { version = "1.50.0", features = ["full"] } -sha2 = "0.11.0" -hex = "*" -serde = "1.0.228" -tempfile = "3.27.0" -anyhow = "1.0.102" -ed25519-dalek = "2.2.0" -serde_json = "1.0" diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs deleted file mode 100644 index ec5d061..0000000 --- a/iota-updater/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod manifest; -pub mod transaction; - -use anyhow::Result; - -/// Compatibility entry point used by the UI. Updates are now manifest-driven; -/// this function only checks and never replaces the invoking executable. -pub async fn check_update() -> Result { - if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() { - return Ok(false); - } - Ok(false) -} diff --git a/iota-updater/src/main.rs b/iota-updater/src/main.rs deleted file mode 100644 index d566dc9..0000000 --- a/iota-updater/src/main.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::Result; - -#[tokio::main] -async fn main() -> Result<()> { - let command = std::env::args().nth(1).unwrap_or_else(|| "status".into()); - match command.as_str() { - "check" => println!("update check is manifest-driven"), - "status" => println!("updater ready"), - "apply" | "rollback" => { - return Err(anyhow::anyhow!( - "explicit signed transaction input is required" - )); - } - _ => { - return Err(anyhow::anyhow!( - "usage: iota-updater check|status|apply|rollback" - )); - } - } - Ok(()) -} diff --git a/iota-updater/src/manifest.rs b/iota-updater/src/manifest.rs deleted file mode 100644 index f2bae0a..0000000 --- a/iota-updater/src/manifest.rs +++ /dev/null @@ -1,83 +0,0 @@ -use anyhow::{Context, Result, bail}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ReleaseManifest { - pub product_version: String, - pub channel: String, - pub published_at: String, - pub minimum_data_schema: u64, - pub supported_ipc_min: u16, - pub supported_ipc_max: u16, - pub artifacts: Vec, - pub release_signing_key_id: String, - pub rollback_compatible: bool, -} -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Artifact { - pub role: String, - pub os: String, - pub architecture: String, - pub path: String, - pub url: String, - pub sha256: String, - pub size: u64, -} - -pub fn canonical_bytes(manifest: &ReleaseManifest) -> Result> { - Ok(serde_json::to_vec(manifest)?) -} -pub fn verify_signature( - manifest: &ReleaseManifest, - signature: &[u8], - public_key: &[u8; 32], -) -> Result<()> { - let key = VerifyingKey::from_bytes(public_key).context("invalid release public key")?; - let signature = Signature::from_slice(signature).context("invalid release signature")?; - key.verify(&canonical_bytes(manifest)?, &signature) - .context("release manifest signature verification failed") -} -pub fn verify_artifact(path: &std::path::Path, artifact: &Artifact) -> Result<()> { - let metadata = std::fs::metadata(path)?; - if metadata.len() != artifact.size { - bail!("artifact size mismatch for {}", artifact.path); - } - let mut file = std::fs::File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0u8; 64 * 1024]; - loop { - let read = std::io::Read::read(&mut file, &mut buffer)?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - let actual = hex::encode(hasher.finalize()); - if actual != artifact.sha256.to_ascii_lowercase() { - bail!("artifact hash mismatch for {}", artifact.path); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn rejects_size_or_hash_mismatch() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("iota-daemon"); - std::fs::write(&path, b"daemon").unwrap(); - let artifact = Artifact { - role: "daemon".into(), - os: "linux".into(), - architecture: "x86_64".into(), - path: "bin/iota-daemon".into(), - url: "https://example.invalid".into(), - sha256: "00".repeat(32), - size: 6, - }; - assert!(verify_artifact(&path, &artifact).is_err()); - } -} diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs deleted file mode 100644 index 342b2f8..0000000 --- a/iota-updater/src/transaction.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::manifest::{Artifact, verify_artifact}; -use anyhow::{Context, Result}; -use std::{ - fs, - path::{Path, PathBuf}, -}; - -#[derive(Clone, Debug)] -pub struct UpdateTransaction { - pub root: PathBuf, - pub staging: PathBuf, - pub lock_file: PathBuf, -} -impl UpdateTransaction { - pub fn new(root: impl Into) -> Self { - let root = root.into(); - Self { - staging: root.join(".staging"), - lock_file: root.join("update.lock"), - root, - } - } - pub fn from_paths(paths: &iota_paths::IotaPaths) -> Result { - Ok(Self { - root: paths.install_root.clone(), - staging: paths.update_staging_dir(), - lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?, - }) - } - pub fn acquire(&self) -> Result { - if let Some(parent) = self.lock_file.parent() { - fs::create_dir_all(parent)?; - } - let file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&self.lock_file) - .context("update already in progress")?; - Ok(file) - } - pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result { - fs::create_dir_all(&self.staging)?; - let target = self.staging.join(&artifact.path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(source, &target)?; - verify_artifact(&target, artifact)?; - Ok(target) - } - pub fn activate(&self, version: &str) -> Result<()> { - let version_dir = self.root.join("versions").join(version); - fs::create_dir_all(version_dir.parent().unwrap())?; - fs::rename(&self.staging, &version_dir).context("activate staged release")?; - let current_tmp = self.root.join("current.new"); - let _ = fs::remove_file(¤t_tmp); - std::os::unix::fs::symlink(&version_dir, ¤t_tmp)?; - fs::rename(current_tmp, self.root.join("current"))?; - Ok(()) - } - pub fn rollback(&self, previous: &str) -> Result<()> { - let current = self.root.join("current"); - let tmp = self.root.join("current.rollback"); - let _ = fs::remove_file(&tmp); - std::os::unix::fs::symlink(self.root.join("versions").join(previous), &tmp)?; - fs::rename(tmp, current)?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn lock_is_exclusive_and_activation_switches_current() { - let dir = tempfile::tempdir().unwrap(); - let tx = UpdateTransaction::new(dir.path()); - let lock = tx.acquire().unwrap(); - assert!(tx.acquire().is_err()); - drop(lock); - std::fs::create_dir_all(&tx.staging).unwrap(); - std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap(); - tx.activate("1.0.0").unwrap(); - assert_eq!( - std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(), - "ok" - ); - } -} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml deleted file mode 100644 index 676d02f..0000000 --- a/iota-util/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "iota-util" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ - "crypto" -] } - -reqwest = "0.13.2" -tokio = { version = "1.50.0", features = ["full"] } -sysinfo = "0.38.0" -uuid = { version = "*", features = ["v4"] } -walkdir = "2.5.0" -zip = "6.0.0" -base64 = "0.22.1" -hex = "*" - -[dev-dependencies] -tempfile = "3" diff --git a/iota-util/src/atomic_file.rs b/iota-util/src/atomic_file.rs deleted file mode 100644 index 9800a91..0000000 --- a/iota-util/src/atomic_file.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -static TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); - -/* Persist small state files without exposing a partially written version after - * a crash. Backups give operators a local recovery point for keys and config. */ -pub fn replace(path: &Path, contents: &[u8], backup_limit: usize) -> io::Result<()> { - replace_with_mode(path, contents, backup_limit, false) -} - -pub fn replace_private(path: &Path, contents: &[u8], backup_limit: usize) -> io::Result<()> { - replace_with_mode(path, contents, backup_limit, true) -} - -fn replace_with_mode( - path: &Path, - contents: &[u8], - backup_limit: usize, - private: bool, -) -> io::Result<()> { - let parent = path.parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "persistent file has no parent directory", - ) - })?; - fs::create_dir_all(parent)?; - - if backup_limit > 0 && path.is_file() { - create_backup(path, backup_limit)?; - } - - let temporary = temporary_path(path)?; - let write_result = (|| { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary)?; - set_private_permissions(&temporary, private)?; - file.write_all(contents)?; - file.sync_all()?; - fs::rename(&temporary, path)?; - sync_directory(parent) - })(); - if write_result.is_err() { - let _ = fs::remove_file(&temporary); - } - write_result -} - -#[cfg(unix)] -fn set_private_permissions(path: &Path, private: bool) -> io::Result<()> { - if private { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; - } - Ok(()) -} - -#[cfg(not(unix))] -fn set_private_permissions(_path: &Path, _private: bool) -> io::Result<()> { - Ok(()) -} - -fn create_backup(path: &Path, backup_limit: usize) -> io::Result<()> { - let parent = path.parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "persistent file has no parent directory", - ) - })?; - let name = path.file_name().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "persistent file has no name") - })?; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let id = TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); - let backup = parent.join(format!( - ".{}.backup-{timestamp}-{id}", - name.to_string_lossy() - )); - fs::copy(path, &backup)?; - File::open(&backup)?.sync_all()?; - sync_directory(parent)?; - - let prefix = format!(".{}.backup-", name.to_string_lossy()); - let mut backups = fs::read_dir(parent)? - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) - .collect::>(); - backups.sort_by_key(|entry| entry.file_name()); - let obsolete = backups.len().saturating_sub(backup_limit); - for entry in backups.into_iter().take(obsolete) { - fs::remove_file(entry.path())?; - } - Ok(()) -} - -fn temporary_path(path: &Path) -> io::Result { - let parent = path.parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "persistent file has no parent directory", - ) - })?; - let name = path.file_name().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "persistent file has no name") - })?; - let id = TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); - Ok(parent.join(format!( - ".{}.{}.{}.tmp", - name.to_string_lossy(), - std::process::id(), - id - ))) -} - -#[cfg(unix)] -fn sync_directory(path: &Path) -> io::Result<()> { - File::open(path)?.sync_all() -} - -#[cfg(not(unix))] -fn sync_directory(_path: &Path) -> io::Result<()> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::replace; - - #[test] - fn replace_preserves_a_previous_version_as_a_backup() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("state"); - replace(&path, b"first", 2).unwrap(); - replace(&path, b"second", 2).unwrap(); - - assert_eq!(std::fs::read(&path).unwrap(), b"second"); - let backups = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(Result::ok) - .filter(|entry| { - entry - .file_name() - .to_string_lossy() - .contains(".state.backup-") - }) - .count(); - assert_eq!(backups, 1); - } -} diff --git a/iota-util/src/crypto_helper.rs b/iota-util/src/crypto_helper.rs deleted file mode 100644 index 339dd1f..0000000 --- a/iota-util/src/crypto_helper.rs +++ /dev/null @@ -1,34 +0,0 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp::crypto::{Keyring, PublicKeyBundle}; - -pub fn generate_keyring() -> Keyring { - Keyring::generate() -} - -pub fn keyring_to_base64(keyring: &Keyring) -> String { - keyring - .try_to_bytes() - .map(|bytes| STANDARD.encode(bytes)) - .unwrap_or_default() -} - -pub fn keyring_from_base64(s: &str) -> Option { - let bytes = STANDARD.decode(s).ok()?; - Keyring::from_bytes(&bytes).ok() -} - -pub fn public_key_bundle_to_base64(bundle: &PublicKeyBundle) -> String { - bundle - .try_as_bytes() - .map(|bytes| STANDARD.encode(bytes)) - .unwrap_or_default() -} - -pub fn public_key_bundle_from_base64(s: &str) -> Option { - let bytes = STANDARD.decode(s).ok()?; - PublicKeyBundle::from_bytes(&bytes).ok() -} - -pub fn hex_hash(input: &str) -> String { - hex::encode(mtp::crypto::sha256(input.as_bytes())) -} diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs deleted file mode 100644 index bbb884d..0000000 --- a/iota-util/src/crypto_util.rs +++ /dev/null @@ -1,121 +0,0 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp::crypto::{ - EncryptionType, Keyring, MultiEncryptedMessage, PublicKeyBundle, decrypt_multi_for, - encrypt_multi_for, -}; - -const CHALLENGE_PURPOSE: u8 = 0x01; -const LEGACY_AAD_DOMAIN: &[u8] = b"IOTA-MTP-AAD-1"; - -fn bind_aad(plaintext: &[u8], aad: &[u8]) -> Result, String> { - let aad_len = u32::try_from(aad.len()) - .map_err(|_| "associated data is too large to encode".to_string())?; - let mut bound = Vec::with_capacity( - LEGACY_AAD_DOMAIN - .len() - .saturating_add(4) - .saturating_add(aad.len()) - .saturating_add(plaintext.len()), - ); - bound.extend_from_slice(LEGACY_AAD_DOMAIN); - bound.extend_from_slice(&aad_len.to_be_bytes()); - bound.extend_from_slice(aad); - bound.extend_from_slice(plaintext); - Ok(bound) -} - -fn unbind_aad(bound: &[u8], aad: &[u8]) -> Result, String> { - let header_len = LEGACY_AAD_DOMAIN.len() + 4; - if bound.len() < header_len || &bound[..LEGACY_AAD_DOMAIN.len()] != LEGACY_AAD_DOMAIN { - return Err("associated-data binding is invalid".to_string()); - } - let length_start = LEGACY_AAD_DOMAIN.len(); - let length_end = length_start + 4; - let aad_len = u32::from_be_bytes( - bound[length_start..length_end] - .try_into() - .map_err(|_| "associated-data length is invalid".to_string())?, - ) as usize; - let aad_start = length_end; - let aad_end = aad_start - .checked_add(aad_len) - .ok_or_else(|| "associated-data length overflows".to_string())?; - if aad_end > bound.len() || &bound[aad_start..aad_end] != aad { - return Err("associated data does not match".to_string()); - } - Ok(bound[aad_end..].to_vec()) -} - -#[derive(Clone, Copy, Debug)] -pub enum DataFormat { - Raw, - Base64, - Hex, -} - -pub fn encrypt( - plaintext: &[u8], - aad: &[u8], - recipient_pub_key_bundle: &PublicKeyBundle, -) -> Result, String> { - let bound_plaintext = bind_aad(plaintext, aad)?; - let encrypted = encrypt_multi_for( - EncryptionType::MlKemChaCha20Poly1305, - CHALLENGE_PURPOSE, - &bound_plaintext, - std::slice::from_ref(recipient_pub_key_bundle), - ) - .map_err(|e| format!("encryption error: {e:?}"))?; - encrypted - .to_bytes() - .map_err(|e| format!("encryption encoding error: {e:?}")) -} - -pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result, String> { - let message = MultiEncryptedMessage::from_bytes(ciphertext) - .map_err(|e| format!("decryption envelope error: {e:?}"))?; - let bound_plaintext = decrypt_multi_for(&message, CHALLENGE_PURPOSE, keyring) - .map_err(|e| format!("decryption error: {e:?}"))?; - unbind_aad(&bound_plaintext, aad) -} - -pub fn encrypt_challenge( - challenge: &str, - recipient_pub_key_bundle: &PublicKeyBundle, -) -> Result { - let blob = encrypt(challenge.as_bytes(), b"challenge", recipient_pub_key_bundle)?; - Ok(STANDARD.encode(&blob)) -} - -pub fn decrypt_challenge(encrypted: &str, keyring: &Keyring) -> Result { - let blob = STANDARD - .decode(encrypted) - .map_err(|e| format!("base64 decode error: {}", e))?; - let pt = decrypt(&blob, b"challenge", keyring)?; - String::from_utf8(pt).map_err(|e| format!("utf8 decode error: {}", e)) -} - -pub fn export(data: &[u8], format: DataFormat) -> Result { - match format { - DataFormat::Raw => { - String::from_utf8(data.to_vec()).map_err(|error| format!("utf8 decode error: {error}")) - } - DataFormat::Base64 => Ok(STANDARD.encode(data)), - DataFormat::Hex => Ok(hex::encode(data)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encrypt_decrypt_binds_associated_data() -> Result<(), String> { - let keyring = Keyring::generate(); - let ciphertext = encrypt(b"challenge", b"context", &keyring.public_key_bundle())?; - - assert_eq!(decrypt(&ciphertext, b"context", &keyring)?, b"challenge"); - assert!(decrypt(&ciphertext, b"other-context", &keyring).is_err()); - Ok(()) - } -} diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs deleted file mode 100644 index 1b87352..0000000 --- a/iota-util/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod atomic_file; -pub mod crypto_helper; -pub mod crypto_util; -pub mod file_util; -pub mod mtp_compat; -pub mod route_target; -pub mod tu; diff --git a/iota-util/src/mtp_compat.rs b/iota-util/src/mtp_compat.rs deleted file mode 100644 index adc47d3..0000000 --- a/iota-util/src/mtp_compat.rs +++ /dev/null @@ -1,111 +0,0 @@ -use mtp::codec::{CommunicationValue, DataValue}; -use mtp::type_map::DataTypeId; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MtpFieldError { - MissingId, - MissingSender, - MissingReceiver, -} - -impl std::fmt::Display for MtpFieldError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::MissingId => "missing message id", - Self::MissingSender => "missing sender", - Self::MissingReceiver => "missing receiver", - }) - } -} - -impl std::error::Error for MtpFieldError {} - -pub trait RequiredCommunicationFields { - fn require_id(&self) -> Result; - fn require_sender(&self) -> Result; - fn require_receiver(&self) -> Result; -} - -impl RequiredCommunicationFields for CommunicationValue { - fn require_id(&self) -> Result { - self.id().ok_or(MtpFieldError::MissingId) - } - - fn require_sender(&self) -> Result { - self.sender().ok_or(MtpFieldError::MissingSender) - } - - fn require_receiver(&self) -> Result { - self.receiver().ok_or(MtpFieldError::MissingReceiver) - } -} - -pub trait OptionalDataValueExt<'a> { - fn as_bool(self) -> Option; - fn as_str(self) -> Option<&'a str>; - fn as_string(self) -> Option; - fn as_number(self) -> Option; - fn as_signed_number(self) -> Option; - fn as_array(self) -> Option>; - fn as_container(self) -> Option>; -} - -impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> { - fn as_bool(self) -> Option { - self.and_then(DataValue::as_bool) - } - - fn as_str(self) -> Option<&'a str> { - self.and_then(DataValue::as_str) - } - - fn as_string(self) -> Option { - self.and_then(DataValue::as_string) - } - - fn as_number(self) -> Option { - self.and_then(DataValue::as_number) - } - - fn as_signed_number(self) -> Option { - self.and_then(DataValue::as_signed_number) - } - - fn as_array(self) -> Option> { - self.and_then(DataValue::as_array) - } - - fn as_container(self) -> Option> { - self.and_then(DataValue::as_container) - } -} - -#[cfg(test)] -mod tests { - use super::{MtpFieldError, RequiredCommunicationFields}; - use mtp::codec::{CommunicationType, CommunicationValue}; - - #[test] - fn missing_routing_fields_are_reported_instead_of_defaulted() { - let message = CommunicationValue::new(CommunicationType::Success).without_id(); - - assert_eq!(message.require_id(), Err(MtpFieldError::MissingId)); - assert_eq!(message.require_sender(), Err(MtpFieldError::MissingSender)); - assert_eq!( - message.require_receiver(), - Err(MtpFieldError::MissingReceiver) - ); - } - - #[test] - fn present_routing_fields_are_returned_unchanged() { - let message = CommunicationValue::new(CommunicationType::Success) - .with_id(7) - .with_sender(8) - .with_receiver(9); - - assert_eq!(message.require_id(), Ok(7)); - assert_eq!(message.require_sender(), Ok(8)); - assert_eq!(message.require_receiver(), Ok(9)); - } -} diff --git a/iota-util/src/route_target.rs b/iota-util/src/route_target.rs deleted file mode 100644 index 43e4249..0000000 --- a/iota-util/src/route_target.rs +++ /dev/null @@ -1,44 +0,0 @@ -const TARGET_KIND_MASK: u64 = 0xC000_0000_0000_0000; -const TARGET_ID_MASK: u64 = (1_u64 << 48) - 1; -const USER_TARGET_KIND: u64 = 0x4000_0000_0000_0000; -const IOTA_TARGET_KIND: u64 = 0x8000_0000_0000_0000; - -/* - * Relay receivers carry their namespace in the wire identity. This prevents - * a user ID and an Iota ID with the same numeric value from selecting the - * wrong connection at an Omikron. - */ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RouteTarget { - User(u64), - Iota(u64), -} - -impl RouteTarget { - pub fn wire_id(self) -> Option { - let (kind, id) = match self { - Self::User(id) => (USER_TARGET_KIND, id), - Self::Iota(id) => (IOTA_TARGET_KIND, id), - }; - (id > 0 && id <= TARGET_ID_MASK).then_some(kind | id) - } - - pub fn from_wire_id(value: u64) -> Option { - let id = value & TARGET_ID_MASK; - if id == 0 || value & !(TARGET_KIND_MASK | TARGET_ID_MASK) != 0 { - return None; - } - - match value & TARGET_KIND_MASK { - USER_TARGET_KIND => Some(Self::User(id)), - IOTA_TARGET_KIND => Some(Self::Iota(id)), - _ => None, - } - } - - pub const fn id(self) -> u64 { - match self { - Self::User(id) | Self::Iota(id) => id, - } - } -} diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs deleted file mode 100644 index 3d05508..0000000 --- a/iota-util/src/tu.rs +++ /dev/null @@ -1,120 +0,0 @@ -/* Strict parsing and storage-independent handling of user credentials. A - * `.tu` file is identified by the account ID in its contents, while storage - * names the file after its owner's username. */ - -use crate::crypto_helper::{keyring_from_base64, keyring_to_base64}; -use mtp::crypto::{Keyring, PublicKeyBundle}; -use std::fmt; - -pub const MAX_PROTOCOL_ID: i64 = (1_i64 << 48) - 1; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TuError { - InvalidFormat, - InvalidUserId, - InvalidKeyring, -} - -impl fmt::Display for TuError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::InvalidFormat => "invalid .tu credential format", - Self::InvalidUserId => "invalid .tu user id", - Self::InvalidKeyring => "invalid .tu keyring", - }) - } -} - -impl std::error::Error for TuError {} - -pub struct TuCredential { - pub user_id: i64, - pub omega_host: String, - pub keyring: Keyring, -} - -impl fmt::Debug for TuCredential { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TuCredential") - .field("user_id", &self.user_id) - .field("omega_host", &self.omega_host) - .field("keyring", &"") - .finish() - } -} - -impl TuCredential { - pub fn parse(input: &str) -> Result { - let (identity, encoded_keyring) = input - .trim() - .split_once("::") - .ok_or(TuError::InvalidFormat)?; - if encoded_keyring.is_empty() || encoded_keyring.contains("::") { - return Err(TuError::InvalidFormat); - } - let (user_id, omega_host) = identity.split_once('@').ok_or(TuError::InvalidFormat)?; - if omega_host.trim().is_empty() || omega_host.contains('@') { - return Err(TuError::InvalidFormat); - } - let user_id = user_id.parse::().map_err(|_| TuError::InvalidUserId)?; - if !(1..=MAX_PROTOCOL_ID).contains(&user_id) { - return Err(TuError::InvalidUserId); - } - let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; - Ok(Self { - user_id, - omega_host: omega_host.trim().to_owned(), - keyring, - }) - } - - pub fn public_key_bundle(&self) -> PublicKeyBundle { - self.keyring.public_key_bundle() - } - - pub fn to_canonical_string(&self) -> String { - format!( - "{}@{}::{}", - self.user_id, - self.omega_host, - keyring_to_base64(&self.keyring) - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::crypto_helper::generate_keyring; - - #[test] - fn round_trip_is_canonical() { - let credential = TuCredential { - user_id: 42, - omega_host: "omega.example:443".into(), - keyring: generate_keyring(), - }; - let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); - assert_eq!(parsed.user_id, 42); - assert_eq!(parsed.omega_host, "omega.example:443"); - assert_eq!( - parsed.to_canonical_string(), - credential.to_canonical_string() - ); - } - - #[test] - fn rejects_malformed_credentials() { - for value in [ - "", - "1@omega", - "@omega::abc", - "0@omega::abc", - "281474976710656@omega::abc", - "1@::abc", - "1@omega::abc::def", - ] { - assert!(TuCredential::parse(value).is_err(), "{value}"); - } - } -} diff --git a/iota/Cargo.toml b/iota/Cargo.toml deleted file mode 100644 index 1fc0858..0000000 --- a/iota/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "iota" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-cli = { path = "../iota-cli" } -iota-ipc = { path = "../iota-ipc" } -iota-installer = { path = "../iota-installer" } -iota-core = { path = "../iota-core" } -iota-process-manager = { path = "../iota-process-manager" } -iota-paths = { path = "../iota-paths" } -iota-terms = { path = "../iota-terms" } -iota-util = { path = "../iota-util" } -tokio = { version = "1.50.0", features = ["full"] } -serde_json = "1" -serde_yaml = "0.9" -clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs deleted file mode 100644 index ca914f8..0000000 --- a/iota/src/cli_args.rs +++ /dev/null @@ -1,736 +0,0 @@ -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; -use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; -use iota_terms::TermsType; -use std::path::PathBuf; - -#[derive(Debug)] -pub struct CliInvocation { - pub theme_override: Option, - pub output: OutputFormat, - pub color: CapabilityPolicy, - pub unicode: CapabilityPolicy, - pub command: Command, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CapabilityPolicy { - Auto, - Always, - Never, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum OutputFormat { - Text, - Json, - Yaml, - Table, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum CliTheme { - Monospace, - Binary, - Ansi, - Surface, -} -impl From for ThemeName { - fn from(value: CliTheme) -> Self { - match value { - CliTheme::Monospace => Self::Monospace, - CliTheme::Binary => Self::Binary, - CliTheme::Ansi => Self::Ansi, - CliTheme::Surface => Self::Surface, - } - } -} - -impl From for OutputFormat { - fn from(value: CliOutputFormat) -> Self { - match value { - CliOutputFormat::Text => OutputFormat::Text, - CliOutputFormat::Json => OutputFormat::Json, - CliOutputFormat::Yaml => OutputFormat::Yaml, - CliOutputFormat::Table => OutputFormat::Table, - } - } -} - -#[derive(Parser, Debug)] -#[command( - name = "iota", - version, - about = "Iota operator console", - arg_required_else_help = false -)] -struct Cli { - #[arg(long, global = true, value_enum)] - theme: Option, - #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] - output: OutputFormat, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] - color: CapabilityPolicy, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] - unicode: CapabilityPolicy, - #[arg(long, global = true)] - no_color: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum CliCommand { - Status, - Tasks, - Users(UsersArgs), - Omikron(OmikronArgs), - Identity(IdentityArgs), - Daemon(DaemonArgs), - Config(ConfigArgs), - Terms(TermsArgs), - RegenerateKeys { - #[arg(long)] - yes: bool, - }, - Components, - Health, - Logs { - #[arg(long, default_value_t = 100)] - limit: usize, - }, - Update(UpdateArgs), - Community(CommunityArgs), - Completions { - shell: String, - }, - Man, -} -#[derive(Args, Debug)] -struct UsersArgs { - #[command(subcommand)] - action: UsersAction, -} -#[derive(Subcommand, Debug)] -enum UsersAction { - List, - Show { - user_id: i64, - }, - Add { - username: Option, - #[arg(long, value_name = "PATH")] - tu: Option, - }, - Release { - user_id: i64, - #[arg(long)] - yes: bool, - }, - Data { - #[command(subcommand)] - action: UserDataAction, - }, - CompleteDelete { - user_id: i64, - #[arg(long, value_name = "PATH")] - tu: Option, - #[arg(long)] - yes: bool, - }, -} -#[derive(Subcommand, Debug)] -enum UserDataAction { - Purge { - user_id: i64, - #[arg(long)] - yes: bool, - }, -} -#[derive(Args, Debug)] -struct OmikronArgs { - #[command(subcommand)] - action: OmikronAction, -} -#[derive(Subcommand, Debug)] -enum OmikronAction { - Reconnect, - Status, -} -#[derive(Args, Debug)] -struct IdentityArgs { - #[command(subcommand)] - action: IdentityAction, -} -#[derive(Subcommand, Debug)] -enum IdentityAction { - Rotate { - #[arg(long)] - yes: bool, - }, -} -#[derive(Args, Debug)] -struct ConfigArgs { - #[command(subcommand)] - action: ConfigAction, -} -#[derive(Subcommand, Debug)] -enum ConfigAction { - Get, - Set { key: String, value: String }, - Reload, -} -#[derive(Args, Debug)] -struct DaemonArgs { - #[command(subcommand)] - action: DaemonAction, -} -#[derive(Subcommand, Debug)] -enum DaemonAction { - Restart { - #[arg(long)] - yes: bool, - }, - Stop { - #[arg(long)] - yes: bool, - }, - Enable { - #[arg(long, value_parser = ["socket", "always-on"])] - mode: String, - }, - DisableStartup, - Status, - StartupStatus, - Start, - RestartService, - StopService, - Install { - #[arg(long)] - bundle: String, - #[arg(long)] - operator: Option, - }, - Bootstrap { - #[arg(long)] - bundle: String, - #[arg(long)] - operator: Option, - }, -} -#[derive(Args, Debug)] -struct UpdateArgs { - #[command(subcommand)] - action: UpdateAction, -} -#[derive(Subcommand, Debug)] -enum UpdateAction { - Check, -} -#[derive(Args, Debug)] -struct CommunityArgs { - #[command(subcommand)] - action: CommunityAction, -} -#[derive(Subcommand, Debug)] -enum CommunityAction { - List, -} -#[derive(Args, Debug)] -struct TermsArgs { - #[command(subcommand)] - action: TermsAction, -} -#[derive(Subcommand, Debug)] -enum TermsAction { - Status { - #[arg(long)] - system: bool, - }, - Show { - document: TermsDocument, - }, - Accept { - #[arg(long)] - system: bool, - }, -} -#[derive(Clone, Copy, Debug, ValueEnum)] -enum TermsDocument { - Eula, - Tos, - Privacy, -} -impl From for TermsType { - fn from(value: TermsDocument) -> Self { - match value { - TermsDocument::Eula => TermsType::EULA, - TermsDocument::Tos => TermsType::TOS, - TermsDocument::Privacy => TermsType::PP, - } - } -} - -#[derive(Debug, PartialEq, Eq)] -pub enum Command { - Dashboard, - Help, - Version, - Completions { - shell: String, - }, - ManPage, - Install { - bundle: String, - operator: Option, - }, - Bootstrap { - bundle: String, - operator: Option, - }, - Status, - Tasks, - UsersList, - UsersShow { - user_id: i64, - }, - UsersAdd { - username: Option, - tu: Option, - }, - UsersRelease { - user_id: i64, - confirmed: bool, - }, - UsersPurgeData { - user_id: i64, - confirmed: bool, - }, - UsersCompleteDelete { - user_id: i64, - tu: Option, - confirmed: bool, - }, - OmikronReconnect, - IdentityRotate { - confirmed: bool, - }, - DaemonRestart { - confirmed: bool, - }, - DaemonStop { - confirmed: bool, - }, - DaemonEnable { - mode: String, - }, - DaemonDisableStartup, - DaemonDaemonStatus, - DaemonStartupStatus, - DaemonStart, - DaemonRestartService, - DaemonStopService, - ConfigGet, - ConfigSet { - key: String, - value: String, - }, - ConfigReload, - OmikronStatus, - RegenerateKeys { - confirmed: bool, - }, - Components, - Health, - Logs { - limit: usize, - }, - UpdateCheck, - CommunityList, - TermsStatus { - system: bool, - }, - TermsShow { - document: TermsType, - }, - TermsAccept { - system: bool, - }, -} -impl CliInvocation { - pub fn parse(args: impl IntoIterator) -> Result { - let args = args.into_iter().collect::>(); - if args.as_slice() == ["help"] { - return Ok(Self::special(Command::Help)); - } - - let config = UiConfig::load_or_default(); - let config_output: OutputFormat = config.cli_output.into(); - let require_confirmation = config.resolve_cli_require_confirmation(); - - let parsed = - Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { - match error.kind() { - ErrorKind::DisplayHelp => return "__help__".to_owned(), - ErrorKind::DisplayVersion => return "__version__".to_owned(), - _ => error.to_string(), - } - }); - let parsed = match parsed { - Ok(parsed) => parsed, - Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), - Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), - Err(error) => return Err(error), - }; - - let output = if parsed.output == OutputFormat::Text { - config_output - } else { - parsed.output - }; - - let resolve_confirmed = |yes_flag: bool| -> bool { - if yes_flag { - return true; - } - !require_confirmation - }; - - let command = match parsed.command { - None => Command::Dashboard, - Some(CliCommand::Status) => Command::Status, - Some(CliCommand::Tasks) => Command::Tasks, - Some(CliCommand::Components) => Command::Components, - Some(CliCommand::Health) => Command::Health, - Some(CliCommand::Completions { shell }) => Command::Completions { shell }, - Some(CliCommand::Man) => Command::ManPage, - Some(CliCommand::Users(users)) => match users.action { - UsersAction::List => Command::UsersList, - UsersAction::Show { user_id } => Command::UsersShow { user_id }, - UsersAction::Add { username, tu } => { - if username.is_some() == tu.is_some() { - return Err( - "users add requires exactly one of or --tu ".into(), - ); - } - Command::UsersAdd { username, tu } - } - UsersAction::Release { user_id, yes } => Command::UsersRelease { - user_id, - confirmed: resolve_confirmed(yes), - }, - UsersAction::Data { - action: UserDataAction::Purge { user_id, yes }, - } => Command::UsersPurgeData { - user_id, - confirmed: resolve_confirmed(yes), - }, - UsersAction::CompleteDelete { user_id, tu, yes } => Command::UsersCompleteDelete { - user_id, - tu, - confirmed: resolve_confirmed(yes), - }, - }, - Some(CliCommand::Omikron(omikron)) => match omikron.action { - OmikronAction::Reconnect => Command::OmikronReconnect, - OmikronAction::Status => Command::OmikronStatus, - }, - Some(CliCommand::Identity(identity)) => match identity.action { - IdentityAction::Rotate { yes } => Command::IdentityRotate { - confirmed: resolve_confirmed(yes), - }, - }, - Some(CliCommand::Config(config)) => match config.action { - ConfigAction::Get => Command::ConfigGet, - ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, - ConfigAction::Reload => Command::ConfigReload, - }, - Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { - confirmed: resolve_confirmed(yes), - }, - Some(CliCommand::Logs { limit }) => Command::Logs { limit }, - Some(CliCommand::Update(update)) => match update.action { - UpdateAction::Check => Command::UpdateCheck, - }, - Some(CliCommand::Community(community)) => match community.action { - CommunityAction::List => Command::CommunityList, - }, - Some(CliCommand::Terms(terms)) => match terms.action { - TermsAction::Status { system } => Command::TermsStatus { system }, - TermsAction::Show { document } => Command::TermsShow { - document: document.into(), - }, - TermsAction::Accept { system } => Command::TermsAccept { system }, - }, - Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { - confirmed: resolve_confirmed(yes), - }, - DaemonAction::Stop { yes } => Command::DaemonStop { - confirmed: resolve_confirmed(yes), - }, - DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, - DaemonAction::DisableStartup => Command::DaemonDisableStartup, - DaemonAction::Status => Command::DaemonDaemonStatus, - DaemonAction::StartupStatus => Command::DaemonStartupStatus, - DaemonAction::Start => Command::DaemonStart, - DaemonAction::RestartService => Command::DaemonRestartService, - DaemonAction::StopService => Command::DaemonStopService, - DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, - DaemonAction::Bootstrap { bundle, operator } => { - Command::Bootstrap { bundle, operator } - } - }, - }; - Ok(Self { - theme_override: parsed.theme.map(Into::into), - output, - color: if parsed.no_color { - CapabilityPolicy::Never - } else { - parsed.color - }, - unicode: parsed.unicode, - command, - }) - } - - fn special(command: Command) -> Self { - Self { - theme_override: None, - output: OutputFormat::Text, - color: CapabilityPolicy::Auto, - unicode: CapabilityPolicy::Auto, - command, - } - } - - #[allow(unused)] - pub fn help_text() -> String { - Cli::command().render_long_help().to_string() - } - - pub fn command_paths() -> Vec { - fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec) { - for subcommand in command.get_subcommands() { - let path = if prefix.is_empty() { - subcommand.get_name().to_owned() - } else { - format!("{prefix} {}", subcommand.get_name()) - }; - if subcommand.get_subcommands().next().is_some() { - collect(subcommand, &path, paths); - } else { - paths.push(path); - } - } - } - let command = Cli::command(); - let mut paths = Vec::new(); - collect(&command, "", &mut paths); - paths - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn removes_global_theme_option() { - let invocation = - CliInvocation::parse(["--theme".into(), "binary".into(), "status".into()]).unwrap(); - assert_eq!(invocation.theme_override, Some(ThemeName::Binary)); - assert_eq!(invocation.command, Command::Status); - } - - #[test] - fn reports_supported_names_for_invalid_theme() { - let error = CliInvocation::parse(["--theme=ultraviolet".into()]).unwrap_err(); - assert!(error.contains(ThemeName::supported_names())); - } - - #[test] - fn parses_structured_output_as_a_global_option() { - let invocation = - CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap(); - assert_eq!(invocation.output, OutputFormat::Json); - assert_eq!(invocation.command, Command::UsersList); - } - - #[test] - fn parses_health() { - let invocation = CliInvocation::parse(["health".into()]).unwrap(); - assert_eq!(invocation.command, Command::Health); - } - - #[test] - fn parses_terminal_capability_overrides() { - let invocation = - CliInvocation::parse(["--color=never".into(), "--unicode".into(), "always".into()]) - .unwrap(); - assert_eq!(invocation.color, CapabilityPolicy::Never); - assert_eq!(invocation.unicode, CapabilityPolicy::Always); - assert_eq!(invocation.command, Command::Dashboard); - } - - #[test] - fn no_color_is_a_compatible_alias() { - let invocation = CliInvocation::parse(["--no-color".into()]).unwrap(); - assert_eq!(invocation.color, CapabilityPolicy::Never); - } - - #[test] - fn supports_standard_help_and_version_flags() { - assert_eq!( - CliInvocation::parse(["-h".into()]).unwrap().command, - Command::Help - ); - assert_eq!( - CliInvocation::parse(["--version".into()]).unwrap().command, - Command::Version - ); - } - - #[test] - fn command_schema_drives_help_and_completion_paths() { - let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users release".to_owned())); - assert!(paths.contains(&"daemon install".to_owned())); - let help = CliInvocation::help_text(); - assert!(help.contains("users")); - assert!(help.contains("--output")); - } - - #[test] - fn parses_install_operator_without_raw_slice_matching() { - let invocation = CliInvocation::parse([ - "daemon".into(), - "install".into(), - "--bundle".into(), - "release.zip".into(), - "--operator".into(), - "alice".into(), - ]) - .unwrap(); - assert_eq!( - invocation.command, - Command::Install { - bundle: "release.zip".into(), - operator: Some("alice".into()), - } - ); - } - - #[test] - fn parses_bootstrap_operator() { - let invocation = CliInvocation::parse([ - "daemon".into(), - "bootstrap".into(), - "--bundle".into(), - "release.zip".into(), - "--operator".into(), - "alice".into(), - ]) - .unwrap(); - assert_eq!( - invocation.command, - Command::Bootstrap { - bundle: "release.zip".into(), - operator: Some("alice".into()), - } - ); - } - - #[test] - fn parses_unconfirmed_destructive_commands_explicitly() { - let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!(invocation.command, Command::DaemonStop { confirmed: true }); - } - - #[test] - fn parses_users_add() { - let invocation = - CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::UsersAdd { - username: Some("alice".into()), - tu: None, - } - ); - } - - #[test] - fn rejects_malformed_users_add_shape() { - assert!( - CliInvocation::parse([ - "users".into(), - "incorrect".into(), - "add".into(), - "alice".into() - ]) - .is_err() - ); - } - - #[test] - fn rejects_unknown_destructive_option() { - assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err()); - } - - #[test] - fn rejects_ambiguous_users_remove() { - let error = - CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap_err(); - assert!(error.contains("remove")); - } - - #[test] - fn parses_users_release_with_confirmation() { - let invocation = CliInvocation::parse([ - "users".into(), - "release".into(), - "42".into(), - "--yes".into(), - ]) - .unwrap(); - assert_eq!( - invocation.command, - Command::UsersRelease { - user_id: 42, - confirmed: true, - } - ); - } - - #[test] - fn parses_omikron_reconnect() { - let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); - assert_eq!(invocation.command, Command::OmikronReconnect); - } - - #[test] - fn parses_identity_rotate_requires_yes() { - let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::IdentityRotate { confirmed: true } - ); - let invocation = - CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::IdentityRotate { confirmed: true } - ); - } - - #[test] - fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() { - assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err()); - } - - #[test] - fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() { - assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err()); - } -} diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs deleted file mode 100644 index c893805..0000000 --- a/iota/src/cli_color.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::env; - -#[derive(Debug, Clone, Copy)] -pub struct ColorConfig { - pub enabled: bool, -} - -impl Default for ColorConfig { - fn default() -> Self { - Self::new() - } -} - -impl ColorConfig { - pub fn new() -> Self { - let enabled = - env::var("NO_COLOR").is_err() && env::var("TERM").map(|t| t != "dumb").unwrap_or(true); - Self { enabled } - } - - pub fn colorize(&self, text: &str, style: Style) -> String { - if !self.enabled { - return text.to_string(); - } - format!("{}{}\x1b[0m", style.prefix(), text) - } -} - -#[derive(Debug, Clone, Copy)] -pub struct Style { - pub fg: Option, - pub bg: Option, - pub bold: bool, -} - -impl Style { - pub const fn new() -> Self { - Self { - fg: None, - bg: None, - bold: false, - } - } - - pub const fn fg(mut self, color: u8) -> Self { - self.fg = Some(color); - self - } - - pub const fn bold(mut self) -> Self { - self.bold = true; - self - } - - fn prefix(&self) -> String { - let mut codes = Vec::new(); - if self.bold { - codes.push("1".to_string()); - } - if let Some(fg) = self.fg { - codes.push(format!("3{}", fg)); - } - if let Some(bg) = self.bg { - codes.push(format!("4{}", bg)); - } - if codes.is_empty() { - String::new() - } else { - format!("\x1b[{}m", codes.join(";")) - } - } -} - -pub const SUCCESS: Style = Style::new().fg(2); -pub const WARNING: Style = Style::new().fg(3); -pub const ERROR: Style = Style::new().fg(1); -pub const INFO: Style = Style::new().fg(4); -pub const MUTED: Style = Style::new().fg(8); -pub const HEADING: Style = Style::new().bold(); - -pub fn success(config: &ColorConfig, text: &str) -> String { - config.colorize(text, SUCCESS) -} - -pub fn warning(config: &ColorConfig, text: &str) -> String { - config.colorize(text, WARNING) -} - -pub fn error(config: &ColorConfig, text: &str) -> String { - config.colorize(text, ERROR) -} - -pub fn info(config: &ColorConfig, text: &str) -> String { - config.colorize(text, INFO) -} - -pub fn muted(config: &ColorConfig, text: &str) -> String { - config.colorize(text, MUTED) -} - -pub fn heading(config: &ColorConfig, text: &str) -> String { - config.colorize(text, HEADING) -} diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs deleted file mode 100644 index 1953e78..0000000 --- a/iota/src/daemon_setup_flow.rs +++ /dev/null @@ -1,245 +0,0 @@ -use crate::startup_error::StartupError; -use iota_cli::{ - ipc_client::IpcClient, - screens::daemon_setup::{ - DaemonLaunchMode, DaemonSetupDecision, DaemonSetupScreen, DaemonStartingScreen, - LaunchOption, - }, - theme::UiConfig, - ui::UI, -}; -use iota_process_manager::ProcessManager; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; -use tokio::sync::oneshot; - -pub struct Capabilities { - pub executable: Result, - pub socket: Result<(), StartupError>, - pub system: Result, StartupError>, -} -pub struct DaemonEndpoints { - pub local: PathBuf, - pub system: PathBuf, -} -pub struct ConnectionContext { - pub ipc: Arc, -} -impl Capabilities { - fn options(&self) -> Vec { - let once = self - .executable - .as_ref() - .and_then(|_| self.socket.as_ref()) - .map(|_| ()) - .map_err(ToString::to_string); - let ui = once.clone(); - let system = self - .system - .as_ref() - .map(|_| ()) - .map_err(ToString::to_string); - vec![ - LaunchOption { - mode: DaemonLaunchMode::Once, - enabled: once.is_ok(), - reason: once.err(), - }, - LaunchOption { - mode: DaemonLaunchMode::WithUi, - enabled: ui.is_ok(), - reason: ui.err(), - }, - LaunchOption { - mode: DaemonLaunchMode::WithSystem, - enabled: system.is_ok(), - reason: system.err(), - }, - ] - } -} -pub async fn run( - ui: Arc, - endpoints: &DaemonEndpoints, - caps: Capabilities, -) -> Result { - // The initial dashboard probe may have raced a daemon that was still - // accepting connections. Re-check both endpoints before offering setup: - // a system-managed daemon normally listens on a different socket from a - // locally launched one. - if let Ok(ipc) = IpcClient::connect(&endpoints.local).await { - return Ok(ConnectionContext { ipc }); - } - if endpoints.system != endpoints.local { - if let Ok(ipc) = IpcClient::connect(&endpoints.system).await { - return Ok(ConnectionContext { ipc }); - } - } - let options = caps.options(); - if !options.iter().any(|o| o.enabled) { - return Err(StartupError::Other( - "No running daemon could be reached, and no daemon launch method is available.".into(), - )); - } - if UiConfig::load() - .map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi) - .unwrap_or(false) - && options[0].enabled - { - if let Ok(context) = start_local_with_ui( - ui.clone(), - caps.executable.as_ref().unwrap(), - &endpoints.local, - ) - .await - { - return Ok(context); - } - if ui.is_shutdown() { - return Err(StartupError::Cancelled); - } - } - loop { - let decision = show( - ui.clone(), - options.clone(), - "The daemon is not running. Choose how to start it.", - ) - .await?; - let DaemonSetupDecision::Start(mode) = decision else { - return Err(StartupError::Cancelled); - }; - ui.set_root_screen(Box::new(DaemonStartingScreen)).await; - let result = match mode { - DaemonLaunchMode::Once | DaemonLaunchMode::WithUi => { - start_local_with_ui( - ui.clone(), - caps.executable.as_ref().unwrap(), - &endpoints.local, - ) - .await - } - DaemonLaunchMode::WithSystem => { - let manager = caps.system.as_ref().unwrap(); - tokio::select! { - result = manager.set_iota_startup_mode(iota_process_manager::StartupMode::SocketActivated) => match result { - Ok(_) => tokio::select! { - result = IpcClient::connect_or_activate(&endpoints.system) => result.map(|ipc| ConnectionContext { ipc }).map_err(|e| StartupError::Other(e.to_string())), - _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), - }, - Err(e) => Err(map_process_manager_error(e)), - }, - _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), - } - } - }; - if ui.is_shutdown() { - return Err(StartupError::Cancelled); - } - match result { - Ok(ipc) => { - if mode == DaemonLaunchMode::WithUi { - let mut cfg = - UiConfig::load().map_err(|error| StartupError::Other(error.to_string()))?; - cfg.daemon_start_policy = iota_cli::theme::DaemonStartPolicy::WithUi; - cfg.save() - .map_err(|error| StartupError::Other(error.to_string()))?; - } - return Ok(ipc); - } - Err(error) => { - let retry = show( - ui.clone(), - options.clone(), - format!("Daemon startup failed: {error}. Select an option to retry, or Exit."), - ) - .await?; - if matches!(retry, DaemonSetupDecision::Exit) { - return Err(StartupError::Cancelled); - } - } - } - } -} - -fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { - use iota_process_manager::ProcessManagerErrorKind; - match error.kind() { - ProcessManagerErrorKind::PermissionDenied => { - StartupError::SystemPermissionDenied(error.to_string()) - } - ProcessManagerErrorKind::TimedOut => StartupError::SystemCommandTimedOut(error.to_string()), - _ => StartupError::Other(error.to_string()), - } -} -async fn start_local_with_ui( - ui: Arc, - exe: &Path, - path: &Path, -) -> Result { - tokio::select! { - result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }), - _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), - } -} -async fn show( - ui: Arc, - options: Vec, - message: impl Into, -) -> Result { - let (tx, rx) = oneshot::channel(); - let screen = DaemonSetupScreen::new(options, message, tx).map_err(|error| { - StartupError::Other(format!("Cannot construct daemon setup screen: {error:?}")) - })?; - ui.set_root_screen(Box::new(screen)).await; - tokio::select! { - decision = rx => Ok(decision.unwrap_or(DaemonSetupDecision::Exit)), - _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_systemd_unit_disables_only_the_system_option() { - let capabilities = Capabilities { - executable: Ok(PathBuf::from("iota-daemon")), - socket: Ok(()), - system: Err(StartupError::Other( - "systemd unit iota-daemon.service was not found".into(), - )), - }; - let options = capabilities.options(); - assert!( - options - .iter() - .any(|option| option.mode == DaemonLaunchMode::Once && option.enabled) - ); - let system = options - .iter() - .find(|option| option.mode == DaemonLaunchMode::WithSystem) - .unwrap(); - assert!(!system.enabled); - assert!(system.reason.as_deref().unwrap().contains("was not found")); - } - - #[test] - fn system_only_capabilities_do_not_select_disabled_local_mode() { - let capabilities = Capabilities { - executable: Err(StartupError::DaemonExecutableMissing(PathBuf::from( - "iota-daemon", - ))), - socket: Err(StartupError::LocalSocketNotWritable( - PathBuf::from("/tmp/iota.sock"), - std::io::Error::other("unavailable"), - )), - system: Err(StartupError::Other("manager unavailable".into())), - }; - let options = capabilities.options(); - assert!(options.iter().all(|option| !option.enabled)); - } -} diff --git a/iota/src/local_daemon.rs b/iota/src/local_daemon.rs deleted file mode 100644 index 4af10ea..0000000 --- a/iota/src/local_daemon.rs +++ /dev/null @@ -1,120 +0,0 @@ -use crate::startup_error::StartupError; -use iota_cli::{ipc_client::IpcClient, ui::UI}; -use std::process::Stdio; -use std::{ - collections::VecDeque, - path::Path, - sync::{Arc, Mutex}, - time::Duration, -}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::{Child, Command}, - time::Instant, -}; - -struct LocalDaemonGuard { - child: Option, - committed: bool, -} -impl LocalDaemonGuard { - fn new(child: Child) -> Self { - Self { - child: Some(child), - committed: false, - } - } - fn commit(mut self) -> Child { - self.committed = true; - self.child.take().expect("local daemon child") - } -} -impl Drop for LocalDaemonGuard { - fn drop(&mut self) { - if !self.committed { - if let Some(mut child) = self.child.take() { - let _ = child.start_kill(); - tokio::spawn(async move { - let _ = child.wait().await; - }); - } - } - } -} - -pub async fn launch( - ui: Arc, - executable: &Path, - socket: &Path, -) -> Result, StartupError> { - let mut child = Command::new(executable) - .env("IOTA_SOCKET", socket) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .kill_on_drop(false) - .spawn() - .map_err(|e| StartupError::DaemonExited { - message: format!("Could not start daemon: {e}"), - })?; - let diagnostics = Arc::new(Mutex::new(VecDeque::::with_capacity(64))); - if let Some(stderr) = child.stderr.take() { - let diagnostics = diagnostics.clone(); - tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let mut recent = diagnostics.lock().unwrap(); - if recent.len() == 64 { - recent.pop_front(); - } - recent.push_back(line); - } - }); - } - let mut guard = LocalDaemonGuard::new(child); - let deadline = Instant::now() + Duration::from_secs(20); - let cancellation = ui.cancellation_token(); - loop { - let result = tokio::select! { - status = guard.child.as_mut().expect("child").wait() => { - let status = match status { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}") }; - return Err(StartupError::DaemonExited { message: format_diagnostic(format!("daemon exited with {status}"), &diagnostics) }); - } - connection = IpcClient::connect(socket) => connection, - _ = cancellation.cancelled() => return Err(StartupError::Cancelled), - _ = tokio::time::sleep_until(deadline) => return Err(StartupError::DaemonExited { message: format_diagnostic("timed out waiting for IPC handshake".into(), &diagnostics) }), - }; - match result { - Ok(client) => { - // The daemon was launched with kill_on_drop(false) so it - // survives after we release the child handle. Let it run - // independently; future CLI instances reconnect via IPC. - let _child = guard.commit(); - return Ok(client); - } - Err(_error) if Instant::now() < deadline => { - tokio::time::sleep(Duration::from_millis(200)).await - } - Err(error) => { - return Err(StartupError::DaemonExited { - message: format_diagnostic( - format!("timed out waiting for IPC handshake: {error}"), - &diagnostics, - ), - }); - } - } - } -} - -fn format_diagnostic(message: String, diagnostics: &Arc>>) -> String { - let lines = diagnostics.lock().unwrap(); - if lines.is_empty() { - message - } else { - format!( - "{message}; daemon stderr: {}", - lines.iter().cloned().collect::>().join(" | ") - ) - } -} diff --git a/iota/src/main.rs b/iota/src/main.rs deleted file mode 100644 index 3746707..0000000 --- a/iota/src/main.rs +++ /dev/null @@ -1,1016 +0,0 @@ -use iota_cli::{ - ipc_client::IpcClient, screens::main_screen::MainScreen, theme, - ui::start_bootstrap_tui_with_theme, -}; -use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult}; -use iota_process_manager::detect; -use std::{path::Path, process::ExitCode, sync::Arc}; - -mod cli_args; -mod cli_color; -mod daemon_setup_flow; -mod local_daemon; -mod startup_error; -mod terms; - -use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; -use cli_color::ColorConfig; -use startup_error::StartupError; - -#[tokio::main(flavor = "multi_thread")] -async fn main() -> ExitCode { - match run().await { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - if !matches!(error, StartupError::Cancelled) { - startup_error::print_error(&error); - } - startup_error::exit_code(&error) - } - } -} - -async fn run() -> Result<(), StartupError> { - let invocation = - CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; - let CliInvocation { - theme_override, - output, - color, - unicode, - command, - } = invocation; - match command { - Command::Help => { - print_help(); - Ok(()) - } - Command::Version => { - println!("iota {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - Command::Completions { shell } => print_completions(&shell), - Command::ManPage => { - print_man_page(); - Ok(()) - } - Command::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await, - Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await, - Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await, - Command::Install { bundle, operator } => { - iota_installer::install_linux_bundle_with_operator( - Path::new(&bundle), - operator.as_deref(), - ) - .map_err(|error| StartupError::Other(format!("Installation failed: {error}"))) - } - Command::Bootstrap { bundle, operator } => { - iota_installer::bootstrap_linux_bundle(Path::new(&bundle), operator.as_deref()) - .map_err(|error| StartupError::Other(format!("Bootstrap failed: {error}"))) - } - command => { - let endpoints = resolve_endpoints()?; - if matches!( - command, - Command::DaemonEnable { .. } - | Command::DaemonDisableStartup - | Command::DaemonStartupStatus - | Command::DaemonStart - | Command::DaemonRestartService - | Command::DaemonStopService - ) { - return run_startup_command(command).await; - } - if !matches!(command, Command::Dashboard) { - let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| { - StartupError::Other(format!("Cannot resolve consent storage: {error}")) - })? - .state_dir; - if !iota_terms::consent::load(&state_dir).has_all_required() { - return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into())); - } - let ipc = tokio::select! { - result = connect_available(&endpoints) => result?, - _ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled), - }; - return run_command(ipc, command, output).await; - } - run_dashboard(theme_override, color, unicode, endpoints).await - } - } -} - -fn resolve_endpoints() -> Result { - let local = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? - .ipc_endpoint - { - iota_paths::IpcEndpoint::UnixSocket(path) => path, - iota_paths::IpcEndpoint::WindowsPipe(name) => { - return Err(StartupError::Other(format!( - "Windows IPC endpoint {name} is not supported by this client build" - ))); - } - }; - let system = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System) - .map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))? - .ipc_endpoint - { - iota_paths::IpcEndpoint::UnixSocket(path) => path, - iota_paths::IpcEndpoint::WindowsPipe(name) => { - return Err(StartupError::Other(format!( - "Windows IPC endpoint {name} is not supported by this client build" - ))); - } - }; - Ok(daemon_setup_flow::DaemonEndpoints { local, system }) -} - -async fn run_startup_command(command: Command) -> Result<(), StartupError> { - let manager = iota_process_manager::detect() - .await - .ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?; - let status = match command { - Command::DaemonEnable { mode } => { - let mode = match mode.as_str() { - "socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated, - "always-on" => iota_process_manager::StartupMode::AlwaysOn, - _ => { - return Err(StartupError::InvalidCommand( - "--mode must be socket or always-on".into(), - )); - } - }; - manager - .enable_startup(mode) - .await - .map_err(|e| StartupError::Other(e.to_string()))? - } - Command::DaemonDisableStartup => manager - .disable_startup() - .await - .map_err(|e| StartupError::Other(e.to_string()))?, - Command::DaemonStartupStatus => { - let status = manager - .iota_startup_status() - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("service active: {}", status.service.active); - println!("service enabled: {}", status.service.enabled); - println!("socket active: {}", status.socket.active); - println!("socket enabled: {}", status.socket.enabled); - println!("detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonStart => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Start) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon started. detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonRestartService => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Restart) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon restarted. detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonStopService => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Stop) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon stopped. detected mode: {:?}", status.detected); - return Ok(()); - } - _ => unreachable!(), - }; - println!("deployment status: {:?}", status.detected); - Ok(()) -} - -async fn connect_available( - endpoints: &daemon_setup_flow::DaemonEndpoints, -) -> Result, StartupError> { - match IpcClient::connect(&endpoints.local).await { - Ok(client) => Ok(client), - Err(local_error) => IpcClient::connect(&endpoints.system) - .await - .map_err(|system_error| { - if system_error.kind() == std::io::ErrorKind::TimedOut { - StartupError::IpcTimedOut(endpoints.system.clone()) - } else if local_error.kind() == std::io::ErrorKind::PermissionDenied { - StartupError::SocketPermissionDenied(endpoints.local.clone()) - } else { - StartupError::Other(format!( - "Could not connect to {} or {}: {local_error}; {system_error}", - endpoints.local.display(), - endpoints.system.display() - )) - } - }), - } -} - -async fn run_dashboard( - theme_override: Option, - color_policy: CapabilityPolicy, - unicode_policy: CapabilityPolicy, - endpoints: daemon_setup_flow::DaemonEndpoints, -) -> Result<(), StartupError> { - use std::io::IsTerminal; - if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { - return Err(StartupError::Terminal( - "stdin and stdout must be interactive terminals".into(), - )); - } - if std::env::var("TERM").as_deref() == Ok("dumb") { - return Err(StartupError::Terminal( - "TERM=dumb does not support the interactive dashboard".into(), - )); - } - let stored_terminal = theme::UiConfig::load().unwrap_or_default(); - let color_policy = match color_policy { - CapabilityPolicy::Auto => match stored_terminal.color { - theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, - theme::TerminalPolicy::Always => CapabilityPolicy::Always, - theme::TerminalPolicy::Never => CapabilityPolicy::Never, - }, - policy => policy, - }; - let unicode_policy = match unicode_policy { - CapabilityPolicy::Auto => match stored_terminal.unicode { - theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, - theme::TerminalPolicy::Always => CapabilityPolicy::Always, - theme::TerminalPolicy::Never => CapabilityPolicy::Never, - }, - policy => policy, - }; - let color_enabled = match color_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => { - std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb") - } - }; - let unicode_enabled = match unicode_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => std::env::var("LC_ALL") - .or_else(|_| std::env::var("LC_CTYPE")) - .or_else(|_| std::env::var("LANG")) - .map(|locale| { - let locale = locale.to_ascii_lowercase(); - locale.contains("utf-8") || locale.contains("utf8") - }) - .unwrap_or(false), - }; - let truecolor_enabled = std::env::var("COLORTERM") - .map(|value| { - let value = value.to_ascii_lowercase(); - value.contains("truecolor") || value.contains("24bit") - }) - .unwrap_or(false); - let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile( - theme::UiConfig::resolve_theme(theme_override), - color_enabled, - unicode_enabled, - truecolor_enabled, - )) - .map_err(|error| StartupError::Terminal(error.to_string()))?; - let ui = session.ui(); - let result = async { - let consent = iota_core::consent_state::check(ui.clone()).await - .map_err(StartupError::Consent)?; - if consent != (true, true) { - return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); - } - persist_dashboard_consent().await?; - let initial = tokio::select! { - result = connect_available(&endpoints) => result, - _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), - }; - let context = match initial { - Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client }, - Err(_) => { - let system = tokio::select! { - manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable), - _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), - }?; - // A missing unit is expected before the system daemon has - // been installed. Keep bootstrap alive and expose that state - // as a disabled setup option instead of treating it as a - // fatal startup error. - let system_capability = tokio::select! { - status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error), - _ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled), - }; - let caps = daemon_setup_flow::Capabilities { - executable: daemon_executable(), - socket: writable_socket_path(&endpoints.local), - system: system_capability, - }; - daemon_setup_flow::run(ui.clone(), &endpoints, caps).await? - } - }; - let ipc = context.ipc.clone(); - ipc.spawn_reconnector(); - ui.attach_daemon(ipc).await; - let main_screen = MainScreen::new(ui.clone()).await; - ui.set_root_screen(Box::new(main_screen)).await; - ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?; - ui.wait_for_shutdown().await; - Ok(()) - }.await; - let render_failure = session.shutdown().await; - // Terminal restoration comes first; then stop IPC background tasks with - // their own bounded shutdown so a lost daemon cannot retain the process. - if let Some(ipc) = ui.ipc().await { - ipc.shutdown().await; - } - - match (result, render_failure) { - (Err(error), _) => Err(error), - (Ok(()), Some(error)) => Err(StartupError::Terminal(error)), - (Ok(()), None) => Ok(()), - } -} - -async fn persist_dashboard_consent() -> Result<(), StartupError> { - let docs = iota_terms::get_current_docs().await.ok_or_else(|| { - StartupError::Consent("Could not verify the current agreements after acceptance.".into()) - })?; - let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?; - let mut record = iota_terms::consent::load(&paths.state_dir); - for document in [&docs.0, &docs.1, &docs.2] { - record.accept(document); - } - iota_terms::consent::save(&paths.state_dir, &record) - .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}"))) -} - -fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { - use iota_process_manager::ProcessManagerErrorKind::*; - match error.kind() { - PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()), - TimedOut => StartupError::SystemCommandTimedOut(error.to_string()), - _ => StartupError::Other(error.to_string()), - } -} - -fn daemon_executable() -> Result { - let candidate = iota_paths::daemon_executable(); - if candidate.is_file() { - Ok(candidate) - } else { - Err(StartupError::DaemonExecutableMissing(candidate)) - } -} - -fn writable_socket_path(path: &Path) -> Result<(), StartupError> { - let parent = path.parent().ok_or_else(|| { - StartupError::LocalSocketNotWritable( - path.to_path_buf(), - std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"), - ) - })?; - std::fs::create_dir_all(parent) - .map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?; - let probe = parent.join(format!(".iota-write-probe-{}", std::process::id())); - std::fs::File::create(&probe) - .map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?; - let _ = std::fs::remove_file(probe); - Ok(()) -} - -fn print_help() { - let color = cli_color::ColorConfig::new(); - println!("{}", cli_color::heading(&color, "Iota Operator Console")); - println!(); - println!("Usage: iota [OPTIONS] [COMMAND]"); - println!(); - println!("{}", cli_color::info(&color, "Commands:")); - println!(" (no command) Launch the interactive dashboard"); - println!(" status Show daemon status"); - println!(" tasks List active tasks"); - println!(" users list List all users"); - println!(" users show Show user details"); - println!(" users add Create a new user"); - println!(" users add --tu Add an existing account credential"); - println!(" users data purge Purge hosted data (requires --yes)"); - println!(" users release Release this Iota (requires --yes)"); - println!(" omikron status Show Omikron connection status"); - println!(" omikron reconnect Reconnect to Omikron"); - println!(" identity rotate Rotate identity keys (requires --yes)"); - println!(" config get Show current configuration"); - println!(" config set Set a configuration value"); - println!(" config reload Reload configuration"); - println!(" health Show component health"); - println!(" components Show component health"); - println!(" logs [--limit N] Show recent log entries"); - println!(" update check Check for updates"); - println!(" community list List communities"); - println!(" terms status Show terms acceptance status"); - println!(" terms show Show a terms document"); - println!(" terms accept Accept required terms"); - println!(" daemon restart Restart the daemon (requires --yes)"); - println!(" daemon stop Stop the daemon (requires --yes)"); - println!(" daemon enable Enable daemon at startup"); - println!(" daemon disable-startup Disable daemon at startup"); - println!(" daemon startup-status Show startup configuration"); - println!(" daemon start Start the daemon"); - println!(" daemon restart-service Restart the daemon service"); - println!(" daemon stop-service Stop the daemon service"); - println!(" daemon install Install from a bundle"); - println!(" daemon bootstrap Install and enable a Linux systemd bundle"); - println!(" help Show this help message"); - println!(" completions Generate shell completions"); - println!(" man Show the man page"); - println!(); - println!("{}", cli_color::info(&color, "Options:")); - println!(" --theme Theme: monospace, binary, ansi, surface"); - println!(" --output Output format: text, json, yaml, table"); - println!(" --color Color: auto, always, never"); - println!(" --unicode Unicode: auto, always, never"); - println!(" --no-color Disable colored output"); - println!(" --yes, -y Confirm destructive operations"); - println!(" -h, --help Show help"); - println!(" -V, --version Show version"); - println!(); - println!("{}", cli_color::info(&color, "Examples:")); - println!(" iota Launch the interactive dashboard"); - println!(" iota status Show daemon status"); - println!(" iota users list --output=json List users in JSON format"); - println!(" iota users add alice Create a user named 'alice'"); - println!(" iota users data purge 42 --yes Purge hosted data"); - println!(" iota config get --output=yaml Show config in YAML format"); - println!(" iota logs --limit 50 Show last 50 log entries"); - println!(" iota completions bash Generate bash completions"); - println!(); - println!("{}", cli_color::info(&color, "Exit Codes:")); - println!(" 0 Success"); - println!(" 1 General error"); - println!(" 2 Invalid command or arguments"); - println!(" 130 Interrupted (Ctrl+C)"); - println!(); - println!("{}", cli_color::muted(&color, "Environment Variables:")); - println!(" NO_COLOR Disable colored output when set"); - println!(" TERM Terminal type (dumb disables colors)"); - println!(" IOTA_THEME Default theme override"); -} - -fn print_completions(shell: &str) -> Result<(), StartupError> { - let command_paths = CliInvocation::command_paths(); - let words = command_paths - .iter() - .flat_map(|command| command.split_whitespace()) - .collect::>() - .into_iter() - .collect::>() - .join(" "); - match shell { - "bash" => println!( - "_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota", - words - ), - "zsh" => println!( - "#compdef iota\n_arguments '1:command:({})' '*::argument:->args'", - words - ), - "fish" => { - for command in words.split_whitespace() { - println!("complete -c iota -f -a '{command}'"); - } - } - _ => { - return Err(StartupError::InvalidCommand( - "completion shell must be bash, zsh, or fish".into(), - )); - } - } - Ok(()) -} - -fn print_man_page() { - println!(".TH IOTA 1"); - println!(".SH NAME\n iota \\- Iota operator console"); - println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); - println!(".SH DESCRIPTION"); - println!("Iota is the operator console for managing Iota daemon instances."); - println!("It provides both an interactive dashboard and headless CLI commands."); - println!(".SH COMMANDS"); - for command in CliInvocation::command_paths() { - println!(".TP\n.B {command}"); - } - println!(".SH GLOBAL OPTIONS"); - println!(".TP\n.B --output text|json|yaml|table"); - println!("Set the output format for headless commands."); - println!(".TP\n.B --color auto|always|never"); - println!("Control colored output."); - println!(".TP\n.B --unicode auto|always|never"); - println!("Control Unicode character rendering."); - println!(".TP\n.B --yes, -y"); - println!("Confirm destructive operations without prompting."); - println!(".SH EXIT CODES"); - println!(".TP\n.B 0"); - println!("Success"); - println!(".TP\n.B 1"); - println!("General error"); - println!(".TP\n.B 2"); - println!("Invalid command or arguments"); - println!(".TP\n.B 130"); - println!("Interrupted (Ctrl+C)"); - println!(".SH EXAMPLES"); - println!(".TP\n.B iota"); - println!("Launch the interactive dashboard"); - println!(".TP\n.B iota status"); - println!("Show daemon status"); - println!(".TP\n.B iota users list --output=json"); - println!("List users in JSON format"); - println!(".TP\n.B iota users add alice"); - println!("Create a user named 'alice'"); - println!(".SH ENVIRONMENT"); - println!(".TP\n.B NO_COLOR"); - println!("Disable colored output when set"); - println!(".TP\n.B TERM"); - println!("Terminal type (dumb disables colors)"); - println!(".TP\n.B IOTA_THEME"); - println!("Default theme override"); -} - -async fn run_command( - ipc: Arc, - command: Command, - output: OutputFormat, -) -> Result<(), StartupError> { - let color = ColorConfig::new(); - let request = match command { - Command::Status => LocalRequest::GetStatus, - Command::Tasks => LocalRequest::ListTasks, - Command::UsersList => LocalRequest::ListUsers, - Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { - username: Some(username), - tu: None, - } => LocalRequest::CreateUser { username }, - Command::UsersAdd { - username: None, - tu: Some(path), - } => { - let contents = std::fs::read_to_string(&path).map_err(|error| { - StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())) - })?; - iota_util::tu::TuCredential::parse(&contents).map_err(|error| { - StartupError::InvalidCommand(format!( - "Invalid credential {}: {error}", - path.display() - )) - })?; - LocalRequest::AttachUserFromTu { - credential: iota_ipc::SecretString(contents), - } - } - Command::UsersAdd { .. } => { - return Err(StartupError::InvalidCommand( - "users add requires exactly one of or --tu ".into(), - )); - } - Command::UsersRelease { - user_id, - confirmed: true, - } => LocalRequest::ReleaseUser { user_id }, - Command::UsersPurgeData { - user_id, - confirmed: true, - } => LocalRequest::PurgeUserData { user_id }, - Command::UsersCompleteDelete { - user_id, - tu, - confirmed: true, - } => { - let credential = match tu { - Some(path) => { - let contents = std::fs::read_to_string(&path).map_err(|error| { - StartupError::InvalidCommand(format!( - "Cannot read {}: {error}", - path.display() - )) - })?; - let parsed = - iota_util::tu::TuCredential::parse(&contents).map_err(|error| { - StartupError::InvalidCommand(format!( - "Invalid credential {}: {error}", - path.display() - )) - })?; - if parsed.user_id != user_id { - return Err(StartupError::InvalidCommand( - "credential user ID does not match complete-delete target".into(), - )); - } - Some(iota_ipc::SecretString(contents)) - } - None => None, - }; - LocalRequest::CompleteDeleteUser { - user_id, - credential, - } - } - Command::OmikronReconnect => LocalRequest::ReconnectOmikron, - Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, - Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, - Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Restart, - }, - Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit { - intent: iota_ipc::ExitIntent::Stop, - }, - Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus, - Command::OmikronStatus => LocalRequest::GetOmikronStatus, - Command::ConfigGet => LocalRequest::GetConfig, - Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value }, - Command::ConfigReload => LocalRequest::ReloadConfig, - Command::Health => LocalRequest::ListComponents, - Command::Components => LocalRequest::ListComponents, - Command::Logs { limit } => LocalRequest::GetLogs { limit }, - Command::UpdateCheck => LocalRequest::CheckUpdate, - Command::CommunityList => LocalRequest::ListCommunities, - Command::UsersRelease { - confirmed: false, .. - } - | Command::UsersPurgeData { - confirmed: false, .. - } - | Command::UsersCompleteDelete { - confirmed: false, .. - } - | Command::IdentityRotate { confirmed: false } - | Command::RegenerateKeys { confirmed: false } - | Command::DaemonRestart { confirmed: false } - | Command::DaemonStop { confirmed: false } => { - return Err(StartupError::InvalidCommand( - "Refusing destructive command without --yes.".into(), - )); - } - Command::Dashboard - | Command::Help - | Command::Version - | Command::Completions { .. } - | Command::ManPage - | Command::Install { .. } - | Command::Bootstrap { .. } - | Command::TermsStatus { .. } - | Command::TermsShow { .. } - | Command::TermsAccept { .. } - | Command::DaemonEnable { .. } - | Command::DaemonDisableStartup - | Command::DaemonStartupStatus - | Command::DaemonStart - | Command::DaemonRestartService - | Command::DaemonStopService => { - return Err(StartupError::InvalidCommand( - "Command cannot be run headlessly.".into(), - )); - } - }; - match ipc - .send_request(request) - .await - .map_err(|e| StartupError::Other(e.to_string()))? - { - ResponseResult::Ok(payload) => { - if !matches!(output, OutputFormat::Text) { - return render_structured(&payload, output); - } - match payload { - ResponsePayload::Status(status) => { - let phase_color = if status.degraded_reason.is_some() { - cli_color::WARNING - } else { - cli_color::SUCCESS - }; - print!( - "{} {}", - cli_color::info(&color, "Phase:"), - color.colorize(&status.phase, phase_color) - ); - if !status.tasks.is_empty() { - print!( - ", {} {}", - cli_color::info(&color, "Tasks:"), - status.tasks.join(", ") - ); - } - if let Some(reason) = status.degraded_reason { - print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason); - } - println!(); - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - println!("{}", cli_color::muted(&color, "No active tasks.")); - } else { - for task in &tasks { - println!("{}", task.name); - } - } - } - ResponsePayload::Users(users) => { - if users.is_empty() { - println!("{}", cli_color::muted(&color, "No users.")); - } else { - for user in &users { - println!( - "{} ({})", - cli_color::heading(&color, &user.username), - user.user_id - ); - } - } - } - ResponsePayload::UserCreated { user_id, username } => { - println!( - "{} {} ({})", - cli_color::success(&color, "Created user"), - cli_color::heading(&color, &username), - user_id - ); - } - ResponsePayload::UserRemoved { user_id } => { - println!("{} {}", cli_color::warning(&color, "Removed user"), user_id); - } - ResponsePayload::UserDataPurged { user_id } => { - println!( - "{} hosted data for {}. Account remains managed by this Iota.", - cli_color::success(&color, "Purged"), - user_id - ); - } - ResponsePayload::Acknowledged { message } => { - println!("{}", message); - } - ResponsePayload::DaemonStatus(status) => { - println!("{}", status.formatted); - } - ResponsePayload::Config(config) => { - println!("{}", config.yaml); - } - ResponsePayload::OmikronStatus(status) => { - println!( - "{}: {}", - cli_color::info(&color, "Connected"), - status.connected - ); - if let Some(id) = status.iota_id { - println!("{}: {}", cli_color::info(&color, "Iota ID"), id); - } - } - ResponsePayload::Components(components) => { - if components.is_empty() { - println!( - "{}", - cli_color::muted(&color, "No component health data available.") - ); - } else { - for comp in &components { - let (status_str, style) = match comp.status { - iota_ipc::HealthStatus::Healthy => ("healthy", cli_color::SUCCESS), - iota_ipc::HealthStatus::Degraded => { - ("degraded", cli_color::WARNING) - } - iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR), - }; - let suffix = comp - .message - .as_deref() - .map(|m| format!(" ({m})")) - .unwrap_or_default(); - println!( - "{:?}: {}{}", - comp.id, - color.colorize(status_str, style), - suffix - ); - } - } - } - ResponsePayload::UserDetail(user) => { - println!( - "{}: {} ({})", - cli_color::info(&color, "User"), - cli_color::heading(&color, &user.username), - user.user_id - ); - if let Some(ref name) = user.display_name { - println!("Display Name: {name}"); - } - println!("Created At: {}", user.created_at); - if !user.trusted_apps.is_empty() { - println!("Trusted Apps: {}", user.trusted_apps.join(", ")); - } - } - ResponsePayload::LogEntries(logs) => { - for entry in &logs.entries { - let ts = entry.timestamp_ms; - let (level, style) = if entry.is_error { - ("ERR", cli_color::ERROR) - } else { - ("INF", cli_color::INFO) - }; - println!( - "[{ts}] {} {}: {}", - color.colorize(level, style), - entry.sender, - entry.message - ); - } - } - ResponsePayload::UpdateStatus(status) => { - if status.available { - println!("{}", cli_color::success(&color, "Update available.")); - } else { - println!("{}", cli_color::info(&color, "Up to date.")); - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - println!("{}", cli_color::muted(&color, "No communities.")); - } else { - for c in &communities { - println!("{} ({})", cli_color::heading(&color, &c.title), c.name); - } - } - } - } - Ok(()) - } - ResponseResult::Error(code) => Err(StartupError::Other(format!( - "Daemon request failed: {code}" - ))), - } -} - -/// The IPC payload is the versioned, tagged schema used by headless clients. -/// Text remains an operator-oriented presentation; JSON and YAML must never -/// require consumers to parse it. -fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { - match output { - OutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!( - "Cannot encode JSON output: {error}" - )))? - ), - OutputFormat::Yaml => print!( - "{}", - serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!( - "Cannot encode YAML output: {error}" - )))? - ), - OutputFormat::Table => render_table(payload), - OutputFormat::Text => unreachable!(), - } - Ok(()) -} - -fn render_table(payload: &ResponsePayload) { - match payload { - ResponsePayload::Users(users) => { - if users.is_empty() { - println!("No users."); - return; - } - println!("{:<8} {}", "ID", "USERNAME"); - println!("{:<8} {}", "--------", "--------"); - for user in users { - println!("{:<8} {}", user.user_id, user.username); - } - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - println!("No active tasks."); - return; - } - println!("{}", "NAME"); - println!("{}", "--------"); - for task in tasks { - println!("{}", task.name); - } - } - ResponsePayload::Components(components) => { - if components.is_empty() { - println!("No component health data available."); - return; - } - println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE"); - println!("{:<20} {:<10} {}", "--------", "--------", "--------"); - for comp in components { - let status_str = match comp.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - let message = comp.message.as_deref().unwrap_or("-"); - println!( - "{:<20} {:<10} {}", - format!("{:?}", comp.id), - status_str, - message - ); - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - println!("No communities."); - return; - } - println!("{:<20} {}", "NAME", "TITLE"); - println!("{:<20} {}", "--------", "--------"); - for c in communities { - println!("{:<20} {}", c.name, c.title); - } - } - ResponsePayload::LogEntries(logs) => { - if logs.entries.is_empty() { - println!("No log entries."); - return; - } - println!( - "{:<20} {:<6} {:<12} {}", - "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE" - ); - println!( - "{:<20} {:<6} {:<12} {}", - "--------", "--------", "--------", "--------" - ); - for entry in &logs.entries { - let level = if entry.is_error { "ERR" } else { "INF" }; - println!( - "{:<20} {:<6} {:<12} {}", - entry.timestamp_ms, level, entry.sender, entry.message - ); - } - } - ResponsePayload::Status(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Phase", status.phase); - if !status.tasks.is_empty() { - println!("{:<15} {}", "Tasks", status.tasks.join(", ")); - } - if let Some(reason) = &status.degraded_reason { - println!("{:<15} {}", "Degraded", reason); - } - } - ResponsePayload::DaemonStatus(status) => { - println!("{}", status.formatted); - } - ResponsePayload::Config(config) => { - println!("{}", config.yaml); - } - ResponsePayload::OmikronStatus(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Connected", status.connected); - if let Some(id) = &status.iota_id { - println!("{:<15} {}", "Iota ID", id); - } - } - ResponsePayload::UpdateStatus(status) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Available", status.available); - } - ResponsePayload::UserCreated { user_id, username } => { - println!("Created user {} ({})", username, user_id); - } - ResponsePayload::UserRemoved { user_id } => { - println!("Removed user {}", user_id); - } - ResponsePayload::UserDataPurged { user_id } => { - println!("Purged hosted data for {}", user_id); - } - ResponsePayload::Acknowledged { message } => { - println!("{}", message); - } - ResponsePayload::UserDetail(user) => { - println!("{:<15} {}", "Field", "Value"); - println!("{:<15} {}", "--------", "--------"); - println!("{:<15} {}", "Username", user.username); - println!("{:<15} {}", "User ID", user.user_id); - if let Some(ref name) = user.display_name { - println!("{:<15} {}", "Display Name", name); - } - println!("{:<15} {}", "Created At", user.created_at); - if !user.trusted_apps.is_empty() { - println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", ")); - } - } - } -} diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs deleted file mode 100644 index 4c46c32..0000000 --- a/iota/src/startup_error.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::{fmt, io, path::PathBuf, process::ExitCode}; - -#[allow(dead_code)] -#[derive(Debug)] -pub enum StartupError { - Cancelled, - DaemonExecutableMissing(PathBuf), - LocalSocketNotWritable(PathBuf, io::Error), - SystemManagerUnavailable, - SystemPermissionDenied(String), - SystemCommandTimedOut(String), - SocketPermissionDenied(PathBuf), - IpcTimedOut(PathBuf), - ProtocolMismatch { daemon: u16, minimum: u16 }, - DaemonExited { message: String }, - IpcBindUnavailable(String), - Terminal(String), - Consent(String), - InvalidCommand(String), - Other(String), -} - -impl StartupError { - pub fn exit_code(&self) -> u8 { - match self { - Self::Cancelled => 130, - Self::InvalidCommand(_) => 2, - _ => 1, - } - } - - pub fn suggestion(&self) -> Option<&'static str> { - match self { - Self::DaemonExecutableMissing(_) => { - Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.") - } - Self::LocalSocketNotWritable(_, _) => { - Some("Check permissions on the parent directory or run as your user (not root).") - } - Self::SystemManagerUnavailable => { - Some("Install systemd or another supported process manager.") - } - Self::SystemPermissionDenied(_) => { - Some("Run with appropriate privileges or use a user-level daemon instead.") - } - Self::SocketPermissionDenied(_) => Some( - "Check file permissions on the socket or ensure the daemon is running as your user.", - ), - Self::IpcTimedOut(_) => Some( - "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", - ), - Self::ProtocolMismatch { .. } => Some("Update your CLI or daemon to match versions."), - Self::DaemonExited { .. } => Some("Restart the daemon with `iota daemon restart`."), - Self::IpcBindUnavailable(_) => Some( - "Another instance may be running. Stop it first or use a different socket path.", - ), - Self::Terminal(_) => { - Some("Use a terminal that supports interactive mode, or run commands headlessly.") - } - Self::Consent(_) => Some( - "Run `iota terms accept` in an interactive terminal to review and accept terms.", - ), - Self::InvalidCommand(_) => Some("Run `iota --help` to see available commands."), - _ => None, - } - } -} -impl fmt::Display for StartupError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Cancelled => f.write_str("Cancelled."), - Self::DaemonExecutableMissing(path) => write!( - f, - "Daemon executable is missing or not executable: {}", - path.display() - ), - Self::LocalSocketNotWritable(path, error) => write!( - f, - "Local socket path is not writable ({}): {error}", - path.display() - ), - Self::SystemManagerUnavailable => { - f.write_str("No supported system process manager is available.") - } - Self::SystemPermissionDenied(message) => { - write!(f, "System-level authorization is required: {message}") - } - Self::SystemCommandTimedOut(command) => { - write!(f, "System command timed out: {command}") - } - Self::SocketPermissionDenied(path) => { - write!(f, "Permission denied for IPC socket {}", path.display()) - } - Self::IpcTimedOut(path) => write!(f, "IPC operation timed out for {}", path.display()), - Self::ProtocolMismatch { daemon, minimum } => write!( - f, - "Daemon protocol {daemon} is incompatible; minimum supported version is {minimum}" - ), - Self::DaemonExited { message } => f.write_str(message), - Self::IpcBindUnavailable(message) => { - write!(f, "Daemon IPC listener is unavailable: {message}") - } - Self::Terminal(message) => write!(f, "Interactive terminal is unavailable: {message}"), - Self::Consent(message) | Self::InvalidCommand(message) | Self::Other(message) => { - f.write_str(message) - } - } - } -} -pub fn exit_code(error: &StartupError) -> ExitCode { - ExitCode::from(error.exit_code()) -} - -pub fn print_error(error: &StartupError) { - let color = crate::cli_color::ColorConfig::new(); - eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error); - if let Some(suggestion) = error.suggestion() { - eprintln!( - " {} {}", - crate::cli_color::info(&color, "hint:"), - suggestion - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn cancellation_and_invalid_commands_have_stable_codes() { - assert_eq!(StartupError::Cancelled.exit_code(), 130); - assert_eq!(StartupError::InvalidCommand("bad".into()).exit_code(), 2); - } - #[test] - fn administrative_errors_are_actionable() { - let error = StartupError::SystemPermissionDenied("run as an administrator".into()); - assert!(error.to_string().contains("authorization")); - assert!(error.to_string().contains("administrator")); - } - #[test] - fn most_errors_have_suggestions() { - assert!( - StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")) - .suggestion() - .is_some() - ); - assert!( - StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")) - .suggestion() - .is_some() - ); - assert!(StartupError::Cancelled.suggestion().is_none()); - } -} diff --git a/iota/src/terms.rs b/iota/src/terms.rs deleted file mode 100644 index b7cdbcb..0000000 --- a/iota/src/terms.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::startup_error::StartupError; -use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms}; -use std::io::{self, IsTerminal, Write}; - -pub enum TermsCommand { - Status { system: bool }, - Show { document: TermsType }, - Accept { system: bool }, -} - -fn state_dir(system: bool) -> Result { - let scope = if system { - iota_paths::Scope::System - } else { - iota_paths::Scope::User - }; - iota_paths::IotaPaths::resolve(scope) - .map(|paths| paths.state_dir) - .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}"))) -} - -pub async fn run(command: TermsCommand) -> Result<(), StartupError> { - match command { - TermsCommand::Status { system } => { - let record = consent::load(&state_dir(system)?); - println!( - "EULA: {}", - if record.eula.is_some() { - "accepted" - } else { - "not accepted" - } - ); - println!( - "Terms of Service: {}", - if record.tos.is_some() { - "accepted" - } else { - "not accepted" - } - ); - println!( - "Privacy Policy: {}", - if record.privacy.is_some() { - "accepted" - } else { - "not accepted" - } - ); - Ok(()) - } - TermsCommand::Show { document } => { - let text = get_terms(document).await.ok_or_else(|| { - StartupError::Consent("Could not fetch the requested terms document.".into()) - })?; - print!("{text}"); - Ok(()) - } - TermsCommand::Accept { system } => accept(system).await, - } -} - -async fn accept(system: bool) -> Result<(), StartupError> { - if !io::stdin().is_terminal() || !io::stdout().is_terminal() { - return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into())); - } - let documents = get_current_docs().await.ok_or_else(|| { - StartupError::Consent( - "Could not fetch the current agreements from the legal endpoint.".into(), - ) - })?; - let mut record = consent::load(&state_dir(system)?); - for document in [&documents.0, &documents.1, &documents.2] { - let text = get_terms(document.doc_type).await.ok_or_else(|| { - StartupError::Consent(format!( - "Could not fetch {}.", - document.doc_type.to_string() - )) - })?; - println!( - "\n===== {} =====\nVersion: {}\nDocument hash: {}\n", - document.doc_type.to_string(), - document.get_version(), - document.get_hash() - ); - print!("{text}\n"); - if !confirm(document)? { - return Err(StartupError::Consent( - "No terms were accepted. Iota remains inactive.".into(), - )); - } - record.accept(document); - } - consent::save(&state_dir(system)?, &record) - .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?; - println!("Terms accepted. Start Iota again to enable services."); - Ok(()) -} - -fn confirm(document: &Doc) -> Result { - let hash = document.get_hash(); - let prefix = hash.get(..10).unwrap_or(&hash); - let expected = format!( - "ACCEPT {} {} {}", - document.doc_type.to_str().to_ascii_uppercase(), - document.get_version(), - prefix - ); - print!("To accept this exact document, type:\n{expected}\n> "); - io::stdout() - .flush() - .map_err(|error| StartupError::Consent(error.to_string()))?; - let mut response = String::new(); - io::stdin() - .read_line(&mut response) - .map_err(|error| StartupError::Consent(error.to_string()))?; - Ok(response.trim() == expected) -} diff --git a/mtp-type-maps b/mtp-type-maps deleted file mode 160000 index 4b82f4f..0000000 --- a/mtp-type-maps +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4b82f4f8139ed9aa74fa86f73ba8f0d565703c86 diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml deleted file mode 100644 index 5902891..0000000 --- a/omikron-connector/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "omikron-connector" -version = "0.1.0" -edition = "2024" - -[dependencies] -async-trait = "0.1.89" -iota-connection = { path = "../iota-connection" } -iota-logger = { path = "../iota-logger" } -iota-state = { path = "../iota-state" } -iota-storage = { path = "../iota-storage" } -iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ - "client", - "crypto", - "files", - "raw", -] } - -dashmap = "6.2.1" -json = "*" -reqwest = "0.13.2" -tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } -uuid = { version = "*", features = ["v4"] } -base64 = "0.22.1" -rand_core = { version = "0.6", features = ["getrandom", "std"] } diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs deleted file mode 100644 index 0561212..0000000 --- a/omikron-connector/src/client.rs +++ /dev/null @@ -1,49 +0,0 @@ -use async_trait::async_trait; -use mtp::codec::CommunicationValue; -use std::time::Duration; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum OmikronError { - Disconnected(String), - Timeout(String), - Authentication(String), - Internal(String), -} - -impl std::fmt::Display for OmikronError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Disconnected(v) - | Self::Timeout(v) - | Self::Authentication(v) - | Self::Internal(v) => f.write_str(v), - } - } -} -impl std::error::Error for OmikronError {} - -pub enum OmikronStartupError { - Construction(String), - InitialConnectionTimeout { - connection: std::sync::Arc, - }, - Authentication { - connection: std::sync::Arc, - }, -} - -#[async_trait] -pub trait OmikronClient: Send + Sync { - async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError>; - async fn await_response( - &self, - value: &CommunicationValue, - timeout: Duration, - ) -> Result; - async fn reconnect(&self) -> Result<(), OmikronError>; - /// Replace the local Iota identity and wait for the new identity to - /// register/authenticate. This is deliberately available while offline: - /// it is the recovery operation for an authentication failure. - async fn rotate_identity(&self) -> Result<(), OmikronError>; - async fn is_connected(&self) -> bool; -} diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs deleted file mode 100644 index 69044c0..0000000 --- a/omikron-connector/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod client; -pub mod omega_discovery; -pub mod omikron_connection; -pub mod user_ops; - -pub use client::{OmikronClient, OmikronError, OmikronStartupError}; -pub use omikron_connection::OmikronConnection; diff --git a/omikron-connector/src/omega_discovery.rs b/omikron-connector/src/omega_discovery.rs deleted file mode 100644 index e623daf..0000000 --- a/omikron-connector/src/omega_discovery.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::env; -use std::time::Duration; - -use mtp::crypto::PublicKeyBundle; - -const OMEGA_API_BASE_DEFAULT: &str = "https://omega.tensamin.net"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); - -pub struct OmikronEndpoint { - pub id: i64, - pub host: String, - pub port: u16, - pub public_key: PublicKeyBundle, -} - -fn api_base() -> String { - env::var("OMEGA_API_URL").unwrap_or_else(|_| OMEGA_API_BASE_DEFAULT.to_string()) -} - -/* The Omega host as stored in `.tu` files: no `https://` scheme, but with port. */ -pub fn omega_host() -> String { - api_base() - .trim_start_matches("https://") - .trim_start_matches("http://") - .to_string() -} - -/* `GET /api/get/omikron` - random connected Omikron. Used on first-ever run; - * the only discovery endpoint with a liveness guarantee. */ -pub async fn discover_random() -> Result { - fetch(&format!("{}/api/get/omikron", api_base())).await -} - -/* `GET /api/get/omikron/{iota_id}` - this Iota's primary Omikron. - * No liveness guarantee (may 404 after restart or point at a stale Omikron); - * fall back to `discover_random`. */ -pub async fn discover_primary(iota_id: u64) -> Result { - fetch(&format!("{}/api/get/omikron/{}", api_base(), iota_id)).await -} - -async fn fetch(url: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .build() - .map_err(|e| format!("Failed to build HTTP client: {}", e))?; - - let body = client - .get(url) - .send() - .await - .map_err(|e| format!("Request to {} failed: {}", url, e))? - .text() - .await - .map_err(|e| format!("Failed to read response body from {}: {}", url, e))?; - - let json = json::parse(&body).map_err(|e| format!("Invalid JSON from {}: {}", url, e))?; - - if json["status"].as_str() != Some("success") { - return Err(format!( - "Omega returned status {:?} for {}", - json["status"].as_str(), - url - )); - } - - let id = json["id"] - .as_i64() - .ok_or_else(|| format!("Missing/invalid \"id\" in response from {}", url))?; - let host = json["ip_address"] - .as_str() - .ok_or_else(|| format!("Missing/invalid \"ip_address\" in response from {}", url))? - .to_string(); - let port = json["port"] - .as_u16() - .ok_or_else(|| format!("Missing/invalid \"port\" in response from {}", url))?; - let public_key_b64 = json["public_key"] - .as_str() - .ok_or_else(|| format!("Missing/invalid \"public_key\" in response from {}", url))?; - let public_key = PublicKeyBundle::from_base64(public_key_b64) - .map_err(|e| format!("Failed to decode public key from {}: {}", url, e))?; - - Ok(OmikronEndpoint { - id, - host, - port, - public_key, - }) -} diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs deleted file mode 100644 index 263910f..0000000 --- a/omikron-connector/src/omikron_connection.rs +++ /dev/null @@ -1,3168 +0,0 @@ -use dashmap::{DashMap, DashSet}; -use iota_logger::{log, log_cv_in, log_cv_out, log_t}; -use iota_state::AppState; -use iota_storage::util::config_util::{CONFIG, modify_config}; -use iota_storage::util::relay_replay; -use iota_storage::util::{chat_files, client_relay_delivery, outgoing_relay, relay_queue}; -use iota_util::crypto_helper::{self, keyring_from_base64}; -use iota_util::crypto_util::{self}; -use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{Keyring, PublicKeyBundle}; -use rand_core::RngCore; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; -use tokio::task::JoinHandle; -use tokio::time::sleep; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use crate::client::{OmikronClient, OmikronError}; -use crate::omega_discovery; - -use iota_connection::message_common::*; -use iota_connection::message_handlers; -use iota_connection::relay::{ - RelayValidationError, forward_verified_relay, open_verified_relay_content, - verify_relay_metadata, -}; -use iota_util::route_target::RouteTarget; - -// ============================================================================ -// Configuration -// ============================================================================ - -const IOTA_KEYRING_PATH: &str = "iota.mk"; -static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); -static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); - -fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) { - let Ok(storage_owner) = i64::try_from(signer_id) else { - return; - }; - if let Err(error) = chat_files::record_delivery_failure( - storage_owner, - storage_owner, - relay_message_id, - failure, - now_millis_i64(), - ) { - log!("Relay delivery failure storage failed: {error}"); - } -} - -/* - * Keeps identity and pinned Omikron key files independent from the process - * working directory, so restarts use the same trusted material. - */ -pub fn configure_identity_path(path: PathBuf) { - let key_path = path.parent().map(|parent| parent.join("omikron.mpkb")); - let _ = IDENTITY_PATH.set(path); - if let Some(key_path) = key_path { - let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path); - } -} -fn identity_path() -> &'static Path { - IDENTITY_PATH - .get() - .map(PathBuf::as_path) - .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) -} -fn omikron_public_key_path() -> &'static Path { - OMIKRON_PUBLIC_KEY_PATH - .get() - .map(PathBuf::as_path) - .unwrap_or_else(|| Path::new("omikron.mpkb")) -} - -fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> { - let temporary = serialization_path(path)?; - mtp::files::save_public_key_bundle(key, &temporary) - .map_err(|error| format!("serialize Omikron public key: {error}"))?; - let bytes = std::fs::read(&temporary) - .map_err(|error| format!("read serialized Omikron public key: {error}")); - let _ = std::fs::remove_file(&temporary); - let bytes = bytes?; - iota_util::atomic_file::replace(path, &bytes, 3) - .map_err(|error| format!("write {}: {error}", path.display())) -} - -fn serialization_path(path: &Path) -> Result { - let parent = path - .parent() - .ok_or_else(|| format!("{} has no parent directory", path.display()))?; - let name = path - .file_name() - .ok_or_else(|| format!("{} has no file name", path.display()))?; - Ok(parent.join(format!( - ".{}.serialize-{}", - name.to_string_lossy(), - Uuid::new_v4() - ))) -} - -const RECONNECT_DELAY: Duration = Duration::from_secs(5); -const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); -const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); -const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); -const TASK_MAX_AGE: Duration = Duration::from_secs(60); -const MAX_CONCURRENT_HANDLERS: usize = 20; -const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; - -#[derive(Debug)] -pub enum IdentityError { - Storage(mtp::files::FileError), - Directory(std::io::Error), - InvalidLegacyIdentity, - Verification(String), -} - -impl std::fmt::Display for IdentityError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Storage(error) => write!(f, "identity storage error: {error}"), - Self::Directory(error) => write!(f, "unable to create identity directory: {error}"), - Self::InvalidLegacyIdentity => f.write_str("legacy identity is invalid"), - Self::Verification(error) => { - write!(f, "persisted identity could not be verified: {error}") - } - } - } -} - -impl std::error::Error for IdentityError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ConnectionAttemptResult { - became_healthy: bool, -} - -fn jittered_reconnect_delay(delay: Duration) -> Duration { - let ceiling_ms = u64::try_from(MAX_RECONNECT_DELAY.as_millis()) - .expect("reconnect ceiling must fit in milliseconds"); - let base_ms = u64::try_from(delay.as_millis().min(u128::from(ceiling_ms))) - .expect("bounded reconnect delay must fit in milliseconds"); - let jitter_span = base_ms / 5; - if jitter_span == 0 { - return Duration::from_millis(base_ms); - } - - let mut rng = rand_core::OsRng; - let range = jitter_span.saturating_mul(2).saturating_add(1); - let offset = (rng.next_u64() % range) as i128 - jitter_span as i128; - let jittered = (base_ms as i128 + offset).clamp(0, i128::from(ceiling_ms)); - Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative")) -} - -fn wire_user_id(user_id: i64) -> u64 { - u64::try_from(user_id).expect("validated user ID is non-negative") -} - -/* - * The identity is stored in the Iota state directory as raw keyring bytes so - * daemon restarts do not depend on a separately managed passphrase. - */ -fn save_keyring_verified(keyring: &Keyring, path: &Path) -> Result<(), IdentityError> { - mtp::files::save_keyring_raw(keyring, path).map_err(IdentityError::Storage)?; - let persisted = mtp::files::load_keyring_raw(path).map_err(IdentityError::Storage)?; - let expected = keyring - .try_to_bytes() - .map_err(|error| IdentityError::Verification(error.to_string()))?; - let actual = persisted - .try_to_bytes() - .map_err(|error| IdentityError::Verification(error.to_string()))?; - if expected != actual { - return Err(IdentityError::Verification( - "persisted keyring differs from the requested identity".into(), - )); - } - Ok(()) -} - -fn load_or_migrate_keyring_at( - path: &Path, - legacy: Option, -) -> Result { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(IdentityError::Directory)?; - } - - match mtp::files::load_keyring_raw(path) { - Ok(keyring) => return Ok(keyring), - Err(mtp::files::FileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(IdentityError::Storage(error)), - } - - let keyring = match legacy { - Some(encoded) => { - keyring_from_base64(&encoded).ok_or(IdentityError::InvalidLegacyIdentity)? - } - None => { - log!( - "No existing Iota identity found at {}; generating a new identity", - path.display() - ); - crypto_helper::generate_keyring() - } - }; - - save_keyring_verified(&keyring, path)?; - Ok(keyring) -} - -// ============================================================================ -// Waiting Task System -// ============================================================================ - -pub struct WaitingTask { - pub task: Box bool + Send + Sync>, - pub inserted_at: Instant, -} - -pub static WAITING_TASKS: LazyLock> = LazyLock::new(|| DashMap::new()); - -pub fn start_task_cleanup_loop() { - tokio::spawn(async { - loop { - sleep(TASK_CLEANUP_INTERVAL).await; - WAITING_TASKS.retain(|_, v| v.inserted_at.elapsed() < TASK_MAX_AGE); - } - }); -} - -// ============================================================================ -// Connection State -// ============================================================================ - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ConnectionState { - Disconnected, - Connecting, - Connected { identified: bool }, -} - -impl ConnectionState { - pub fn is_connected(&self) -> bool { - matches!(self, ConnectionState::Connected { .. }) - } - - pub fn is_identified(&self) -> bool { - matches!(self, ConnectionState::Connected { identified: true }) - } -} - -// ============================================================================ -// Omikron Connection (Client-side with auto-reconnect) -// ============================================================================ - -#[allow(dead_code)] // message_send_times is unused. -pub struct OmikronConnection { - state: Arc>, - state_watch_tx: watch::Sender, - sender: Arc>>>, - connection_loop_handle: Arc>>>, - pub last_ping: Arc>, - maintenance_handle: Arc>>>, - pub connection_id: Uuid, - shutdown_tx: Arc>>>, - reconnect_on_close: Arc>, - auth_failure: Arc>>, - keyring: Arc>>>, - pub app_challenges: Arc>, - pub app_sessions: Arc>, - handler_semaphore: Arc, - cancellation: CancellationToken, - pub(crate) active_tasks: Arc>, - pub(crate) app: Arc>, -} - -impl OmikronConnection { - pub fn new(active_tasks: Arc>, app: Arc>) -> Self { - Self::with_cancellation(CancellationToken::new(), active_tasks, app) - } - - pub fn with_cancellation( - cancellation: CancellationToken, - active_tasks: Arc>, - app: Arc>, - ) -> Self { - let (shutdown_tx, _) = watch::channel(false); - let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected); - - OmikronConnection { - state: Arc::new(RwLock::new(ConnectionState::Disconnected)), - state_watch_tx, - sender: Arc::new(RwLock::new(None)), - connection_loop_handle: Arc::new(Mutex::new(None)), - last_ping: Arc::new(Mutex::new(-1)), - maintenance_handle: Arc::new(Mutex::new(None)), - connection_id: Uuid::new_v4(), - shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), - reconnect_on_close: Arc::new(RwLock::new(true)), - auth_failure: Arc::new(RwLock::new(None)), - keyring: Arc::new(RwLock::new(None)), - app_challenges: Arc::new(DashMap::new()), - app_sessions: Arc::new(DashMap::new()), - handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), - cancellation, - active_tasks, - app, - } - } - - async fn set_state(&self, new_state: ConnectionState) { - *self.state.write().await = new_state; - let _ = self.state_watch_tx.send(new_state); - } - - /// Subscribe to connection transitions for daemon health reporting. - pub fn connection_state(&self) -> watch::Receiver { - self.state_watch_tx.subscribe() - } - - // ------------------------------------------------------------------------- - // Connection Management - // ------------------------------------------------------------------------- - - pub async fn connect(self: &Arc) { - if self.connection_loop_handle.lock().await.is_none() { - self.clone().start().await; - } - } - - pub async fn start(self: Arc) { - if let Some(handle) = self.connection_loop_handle.lock().await.take() { - handle.abort(); - } - - if self.shutdown_tx.lock().await.is_none() { - let (shutdown_tx, _) = watch::channel(false); - *self.shutdown_tx.lock().await = Some(shutdown_tx); - } - - *self.reconnect_on_close.write().await = true; - - let self_clone = self.clone(); - let handle = tokio::spawn(async move { - self_clone.connection_loop().await; - }); - - *self.connection_loop_handle.lock().await = Some(handle); - } - - pub async fn stop(&self) { - *self.reconnect_on_close.write().await = false; - - if let Some(tx) = self.shutdown_tx.lock().await.take() { - let _ = tx.send(true); - } - - if let Some(handle) = self.connection_loop_handle.lock().await.take() { - handle.abort(); - } - - if let Some(handle) = self.maintenance_handle.lock().await.take() { - handle.abort(); - } - - if let Some(sender) = self.sender.read().await.as_ref() { - sender.close().await; - } - - self.set_state(ConnectionState::Disconnected).await; - *self.sender.write().await = None; - } - - async fn connection_loop(self: Arc) { - let mut reconnect_delay = RECONNECT_DELAY; - let shutdown_rx = self.shutdown_tx.lock().await.as_ref().unwrap().subscribe(); - let mut shutdown_rx = shutdown_rx; - - loop { - if *shutdown_rx.borrow() || self.cancellation.is_cancelled() { - log_t!("omikron_connection_loop_shutdown"); - break; - } - - if !*self.reconnect_on_close.read().await { - break; - } - - let retry_reason = match self.clone().connect_once().await { - Ok(result) => { - if result.became_healthy { - reconnect_delay = RECONNECT_DELAY; - } - if !*self.reconnect_on_close.read().await { - break; - } - "Connection lost".to_string() - } - Err(e) => { - if self.auth_failure.read().await.is_some() { - log!("Authentication failed, stopping reconnection: {}", e); - break; - } - format!("Connection failed: {e}") - } - }; - - let delay = jittered_reconnect_delay(reconnect_delay); - log!("{}, retrying in {:?}...", retry_reason, delay); - tokio::select! { - _ = sleep(delay) => {} - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - break; - } - } - } - - reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY); - } - } - - async fn connect_once(self: Arc) -> Result { - self.set_state(ConnectionState::Connecting).await; - log_t!("omikron_connecting"); - - let keyring = Arc::new( - self.load_or_migrate_keyring() - .await - .map_err(|error| format!("Iota identity initialization failed: {error}"))?, - ); - *self.keyring.write().await = Some(keyring.clone()); - - let existing_iota_id = CONFIG.load().iota_id; - - let (host, port, omikron_public_key) = - self.resolve_omikron_endpoint(existing_iota_id).await?; - - let addr_str = format!("https://{}:{}", host, port); - - log!("Connecting to Omikron at {}", addr_str); - - let policy = Policy::default() - .with_send_mode(SendMode::SingleStreamPerMessage) - .with_timeouts( - Duration::from_millis(2_000), - Duration::from_millis(2_000), - Duration::from_millis(30_000), - ) - .with_keep_alive(Some(Duration::from_secs(6))) - .with_receiver_queue_capacity(1000) - .with_max_concurrent_stream_tasks(10) - .with_persistent_stream_retries(5, Duration::from_secs(5)); - let client_config = ClientConfig::new(&addr_str) - .with_description("iota") - .with_policy(policy) - .with_ping_interval(MAINTENANCE_INTERVAL); - - let connection = match Client::auth_connect_or_register( - client_config, - existing_iota_id, - &keyring, - &omikron_public_key, - ) - .await - { - Ok(connection) => connection, - Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => { - let reason = format!( - "Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.", - reason - ); - *self.reconnect_on_close.write().await = false; - *self.auth_failure.write().await = Some(reason.clone()); - self.set_state(ConnectionState::Disconnected).await; - return Err(reason); - } - Err(e) => return Err(format!("Connection failed: {}", e)), - }; - - log_t!("omikron_connection_success"); - - if existing_iota_id.is_none() { - modify_config(|cfg| cfg.iota_id = Some(connection.client_id)); - log!("Registered with Iota-ID: {}", connection.client_id); - } - - let sender_arc = Arc::new(connection.sender.clone()); - *self.sender.write().await = Some(sender_arc.clone()); - self.set_state(ConnectionState::Connected { identified: true }) - .await; - - // Start read loop - let connection = Arc::new(connection); - let read_self = self.clone(); - let read_connection = connection.clone(); - let read_handle = tokio::spawn(async move { - read_self.read_loop(read_connection).await; - }); - - log_t!("omikron_authenticated"); - - self.classify_legacy_pending_relays().await; - self.flush_pending_relays().await; - - let maintenance_self = self.clone(); - let maintenance_handle = tokio::spawn(async move { - maintenance_self.maintenance_loop(connection).await; - }); - *self.maintenance_handle.lock().await = Some(maintenance_handle); - - { - self.active_tasks.insert("Omikron Listener".to_string()); - } - - // Wait for read loop to complete - let result = read_handle.await; - *self.sender.write().await = None; - self.set_state(ConnectionState::Disconnected).await; - { - self.active_tasks.remove("Omikron Listener"); - } - - if let Some(handle) = self.maintenance_handle.lock().await.take() { - handle.abort(); - } - - match result { - Ok(()) => Ok(ConnectionAttemptResult { - became_healthy: true, - }), - Err(e) => Err(format!("Read loop error: {}", e)), - } - } - - // ------------------------------------------------------------------------- - // Identity (own Keyring, migrated from the legacy base64-in-config format) - // ------------------------------------------------------------------------- - - async fn load_or_migrate_keyring(&self) -> Result { - load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone()) - } - - // ------------------------------------------------------------------------- - // Omikron discovery (via Omega's HTTP API, replacing the static - // host/port/public-key-file model) - // ------------------------------------------------------------------------- - - /* - * Discovery runs fresh on every `connect_once()` attempt rather than once - * at construction, since a fixed `OmikronConnection` may need to move to - * a different Omikron across reconnects (e.g. after the sticky/primary - * Omikron dies). `OMIKRON_HOST`/`OMIKRON_PORT` remain as a manual - * override for local dev/testing against a hand-run Omikron without a - * live Omega. - * - * The fetched Omikron public key is pinned to `omikron.mpkb` (trust on - * first use): if a cached key exists and a fresh discovery response - * disagrees with it, the mismatch is logged loudly and the cached key is - * kept rather than silently trusting whatever Omega's HTTP API returned - * this time - the same trust boundary the previous manual-file-drop - * model had, just automated for the common case. - */ - async fn resolve_omikron_endpoint( - &self, - existing_iota_id: Option, - ) -> Result<(String, u16, PublicKeyBundle), String> { - if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) { - let port: u16 = port_str - .parse() - .map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?; - let key_path = omikron_public_key_path(); - let public_key = mtp::files::load_public_key_bundle(key_path) - .map_err(|e| { - format!( - "Failed to load Omikron public key bundle from {}: {}. Obtain {} from the Omikron operator and place it at that path.", - key_path.display(), e, key_path.display() - ) - })?; - return Ok((host, port, public_key)); - } - - let key_path = omikron_public_key_path(); - let cached_key = mtp::files::load_public_key_bundle(key_path).ok(); - let cached_host_port = { - let conf = CONFIG.load(); - match (&conf.omikron_host, conf.omikron_port) { - (Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => { - Some((host.clone(), port)) - } - (Some(_), Some(_)) => { - log!("Ignoring invalid cached Omikron endpoint in Iota configuration"); - None - } - _ => None, - } - }; - - let discovered = match existing_iota_id { - Some(id) => match omega_discovery::discover_primary(id).await { - Ok(endpoint) => Some(endpoint), - Err(e) => { - log!( - "Sticky Omikron discovery failed ({}), falling back to a random Omikron", - e - ); - omega_discovery::discover_random().await.ok() - } - }, - None => omega_discovery::discover_random().await.ok(), - }; - - let (host, port, public_key) = if let Some(endpoint) = discovered { - match &cached_key { - Some(cached) => { - let keys_match = - match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) { - (Ok(cached_bytes), Ok(discovered_bytes)) => { - cached_bytes == discovered_bytes - } - _ => false, - }; - if !keys_match { - log!( - "Fetched Omikron public key differs from the cached {} - keeping the \ - cached key. Delete {} manually if this is an expected key rotation.", - key_path.display(), - key_path.display() - ); - if let Some((cached_host, cached_port)) = &cached_host_port { - (cached_host.clone(), *cached_port, cached.clone()) - } else { - return Err(format!( - "Omega returned an Omikron key that differs from {} and no validated cached endpoint is available", - key_path.display() - )); - } - } else { - (endpoint.host, endpoint.port, cached.clone()) - } - } - None => { - if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) { - log!("Failed to cache Omikron public key: {}", e); - } - (endpoint.host, endpoint.port, endpoint.public_key) - } - } - } else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) { - log!( - "Omega discovery unreachable, falling back to last-known Omikron {}:{}", - host, - port - ); - (host.clone(), *port, cached.clone()) - } else { - return Err( - "Omega discovery failed and no cached Omikron address/key is available".to_string(), - ); - }; - - modify_config(|cfg| { - cfg.omikron_host = Some(host.clone()); - cfg.omikron_port = Some(port); - }); - - Ok((host, port, public_key)) - } - - // ------------------------------------------------------------------------- - // Read Loop & Maintenance - // ------------------------------------------------------------------------- - - async fn read_loop(self: Arc, connection: Arc) { - loop { - let result = connection.receive().await; - match result { - Ok(cv) => { - if cv.is_type(CommunicationType::Relay) { - let permit = self.handler_semaphore.clone().acquire_owned().await; - let self_clone = self.clone(); - tokio::spawn(async move { - let _permit = permit; - self_clone.handle_relay(cv).await; - }); - continue; - } - let Some(msg_id) = cv.id() else { - let self_clone = self.clone(); - tokio::spawn(async move { - self_clone.handle_message_impl(cv).await; - }); - continue; - }; - if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { - if (task.task)(cv.clone()) { - continue; - } - } - let permit = self.handler_semaphore.clone().acquire_owned().await; - let self_clone = self.clone(); - tokio::spawn(async move { - let _permit = permit; - self_clone.handle_message_impl(cv).await; - }); - } - Err(e) => { - self.fail_all_waiting_tasks(format!( - "Connection receive error: {} (connection_id={})", - e, self.connection_id - )) - .await; - break; - } - } - if !connection.receiver.is_open() { - self.fail_all_waiting_tasks(format!( - "Connection closed (connection_id={}, receiver_open=false)", - self.connection_id - )) - .await; - break; - } - } - } - - async fn maintenance_loop(self: Arc, connection: Arc) { - loop { - sleep(MAINTENANCE_INTERVAL).await; - - if !self.state.read().await.is_connected() { - break; - } - - if let Some(sender) = self.sender.read().await.as_ref() { - if !sender.is_open() { - break; - } - } else { - break; - } - - if let Some(ping) = connection.get_ping() { - let ping_ms = i64::try_from(ping.as_millis()).unwrap_or(i64::MAX); - *self.last_ping.lock().await = ping_ms; - self.app.lock().unwrap().push_ping_val(ping_ms as f64); - } - - self.classify_legacy_pending_relays().await; - self.flush_pending_relays().await; - if let Err(error) = relay_replay::prune_completed( - now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS), - ) { - log!("Relay replay cleanup failed: {}", error); - } - } - } - - async fn resolve_relay_signing_keys( - &self, - signer_id: u64, - ) -> Result, RelayValidationError> { - let signer_id_i64 = i64::try_from(signer_id).map_err(|_| { - RelayValidationError::KeyLookup("signer ID exceeds local storage range".into()) - })?; - let local_user = iota_storage::users::user_manager::get_user(signer_id_i64) - .map_err(|error| RelayValidationError::KeyLookup(error.to_string()))?; - if let Some(user) = local_user { - let key = iota_util::crypto_helper::public_key_bundle_from_base64(&user.public_key) - .ok_or_else(|| { - RelayValidationError::KeyLookup("stored user key is invalid".into()) - })?; - return Ok(vec![key]); - } - - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::UnsignedNumber(u128::from(signer_id)), - ); - let response = self - .await_response(&request, Some(Duration::from_secs(10))) - .await - .map_err(RelayValidationError::KeyLookup)?; - if !response.is_type(CommunicationType::GetUserData) { - return Err(RelayValidationError::KeyLookup( - "Omega returned an unexpected user lookup response".into(), - )); - } - let public_key = response - .get_data(DataType::PublicKey) - .and_then(|value| value.as_str()) - .ok_or_else(|| RelayValidationError::KeyLookup("Omega returned no user key".into()))?; - let key = iota_util::crypto_helper::public_key_bundle_from_base64(public_key).ok_or_else( - || RelayValidationError::KeyLookup("Omega returned an invalid user key".into()), - )?; - Ok(vec![key]) - } - - pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { - let user_id_i64 = i64::try_from(user_id) - .map_err(|_| "user ID exceeds local storage range".to_string())?; - if iota_storage::users::user_manager::get_user(user_id_i64) - .map_err(|error| error.to_string())? - .is_some() - { - return CONFIG - .load() - .iota_id - .ok_or_else(|| "Iota identity is not configured".into()); - } - - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::UnsignedNumber(u128::from(user_id)), - ); - let response = self - .await_response(&request, Some(Duration::from_secs(10))) - .await?; - response - .get_data(DataType::IotaId) - .and_then(|value| value.as_number()) - .and_then(|value| u64::try_from(value).ok()) - .filter(|value| *value > 0) - .ok_or_else(|| "Omega returned no hosting Iota for the user".into()) - } - - async fn send_relay_response(&self, frame_id: Option, response_type: CommunicationType) { - if let Some(frame_id) = frame_id { - let response = CommunicationValue::new(response_type).with_id(frame_id); - if let Err(error) = self.send_message(&response).await { - log!("Relay response could not be sent: {}", error); - } - } - } - - async fn send_relay_success( - &self, - frame_id: Option, - iota_id: u64, - relay_message_id: &str, - accepted_at: i64, - include_origin_timestamp: bool, - ) { - let Some(frame_id) = frame_id else { return }; - let response = CommunicationValue::new(CommunicationType::Success) - .with_id(frame_id) - .add_typed_default(DataType::IotaId, DataValue::UnsignedNumber(iota_id.into())) - .add_typed_default( - DataType::RelayMessageId, - DataValue::Str(relay_message_id.to_string()), - ) - .add_typed_default( - DataType::RelayAcceptedAt, - DataValue::SignedNumber(accepted_at.into()), - ); - let response = if include_origin_timestamp { - response - .add_typed_default( - DataType::OriginIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - .add_typed_default( - DataType::DestinationIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - } else { - response - }; - if let Err(error) = self.send_message(&response).await { - log!("Relay response could not be sent: {}", error); - } - } - - async fn handle_relay(self: Arc, frame: CommunicationValue) { - let Some(incoming_frame_id) = frame.id() else { - log!("Rejecting Relay without a message id"); - return; - }; - let Some(local_iota_id) = CONFIG.load().iota_id else { - log!("Rejecting Relay because this Iota has no registered identity"); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) - .await; - return; - }; - let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { - log!("Rejecting Relay because the Iota keyring is unavailable"); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) - .await; - return; - }; - - let resolver_connection = self.clone(); - let verified = verify_relay_metadata( - &frame, - local_iota_id, - &keyring, - move |signer_id| async move { - resolver_connection - .resolve_relay_signing_keys(signer_id) - .await - }, - ) - .await; - - let verified = match verified { - Ok(value) => value, - Err(error) => { - log!("Relay metadata verification failed: {}", error); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let accepted_at = now_millis_i64(); - let signer_id = match i64::try_from(verified.context.signer_id) { - Ok(id) => id, - Err(_) => { - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let recipient_id = match i64::try_from(verified.context.final_recipient_id) { - Ok(id) => id, - Err(_) => { - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let signer_is_local = match iota_storage::users::user_manager::get_user(signer_id) { - Ok(user) => user.is_some(), - Err(error) => { - log!( - "Relay locality lookup failed for signer {}: {}", - signer_id, - error - ); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let recipient_is_local = match iota_storage::users::user_manager::get_user(recipient_id) { - Ok(user) => user.is_some(), - Err(error) => { - log!( - "Relay locality lookup failed for recipient {}: {}", - recipient_id, - error - ); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) - .await; - return; - } - }; - if !signer_is_local && !recipient_is_local { - log!( - "Rejecting Relay with no local origin or destination: signer {}, recipient {}", - verified.context.signer_id, - verified.context.final_recipient_id, - ); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) - .await; - return; - } - - /* Evaluate recipient policy before reserving relay replay state or - * persisting the frame, so blocked traffic leaves no durable trace. */ - if recipient_is_local { - match iota_storage::util::blocked_users::is_blocked(recipient_id, signer_id) { - Ok(true) => { - log!( - "Rejecting Relay from blocked signer {} to recipient {}", - signer_id, - recipient_id - ); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorNotAuthenticated, - ) - .await; - return; - } - Ok(false) => {} - Err(error) => { - log!( - "Relay block policy lookup failed for recipient {}: {}", - recipient_id, - error - ); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInternal, - ) - .await; - return; - } - } - } - - /* An origin Iota is authoritative for receipt disclosure. Inspect - * local-origin relay content before reserving or queuing the frame. */ - if signer_is_local { - let content = match open_verified_relay_content( - &verified, - &[&keyring], - verified.context.signer_id, - ) { - Ok(content) => content, - Err(error) => { - log!("Relay origin content verification failed: {}", error); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - if let Err(error) = message_handlers::validate_outgoing_receipt_policy( - signer_id, - &verified.context, - &content, - ) { - log!("Rejecting local receipt relay: {}", error); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - } - - let frame_bytes = match frame.clone().without_id().to_bytes() { - Ok(bytes) => bytes, - Err(error) => { - log!( - "Relay could not be serialized for durable acceptance: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let type_map_version = verified.context.type_map.version.to_string(); - let frame_id = incoming_frame_id; - let reservation = match relay_replay::reserve( - verified.context.signer_id, - &verified.context.message_id, - verified.context.created_at, - accepted_at, - verified.context.final_recipient_id, - &frame_bytes, - frame_id, - &type_map_version, - ) { - Ok(value) => value, - Err(error) => { - log!("Relay durable acceptance failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let already_applied = match reservation { - relay_replay::RelayReservation::New => false, - relay_replay::RelayReservation::Existing { - frame_matches: false, - .. - } => { - log!( - "Rejecting Relay identity collision for signer {} and message {}", - verified.context.signer_id, - verified.context.message_id - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - relay_replay::RelayReservation::Existing { ref state, .. } if state == "delivered" => { - self.send_relay_response(frame.id(), CommunicationType::Success) - .await; - return; - } - relay_replay::RelayReservation::Existing { ref state, .. } - if state == "applied" || state == "queued" => - { - true - } - relay_replay::RelayReservation::Existing { ref state, .. } if state == "rejected" => { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - relay_replay::RelayReservation::Existing { .. } => false, - }; - - /* A shared Iota owns both independent replicas before delivering to its - * local recipient. The destination path below writes the recipient copy. */ - if signer_is_local && recipient_is_local && !already_applied { - let content = match open_verified_relay_content( - &verified, - &[&keyring], - verified.context.final_recipient_id, - ) { - Ok(value) => value, - Err(error) => { - log!( - "Relay shared-Iota origin content verification failed: {}", - error - ); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - let owner = match i64::try_from(verified.context.signer_id) { - Ok(value) => value, - Err(_) => { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - owner, - true, - ) { - log!("Relay shared-Iota origin application failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - if let Err(error) = chat_files::record_destination_iota_received( - owner, - owner, - &verified.context.message_id, - accepted_at, - ) { - log!( - "Relay shared-Iota destination timestamp storage failed: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if signer_is_local && !recipient_is_local { - if !already_applied { - let content = match open_verified_relay_content( - &verified, - &[&keyring], - verified.context.signer_id, - ) { - Ok(value) => value, - Err(error) => { - log!("Relay origin content verification failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - i64::try_from(verified.context.signer_id).unwrap_or_default(), - true, - ) { - log!("Relay origin application failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - } - let router = match self - .hosting_iota_for_user(verified.context.final_recipient_id) - .await - { - Ok(destination_iota) => destination_iota, - Err(error) => { - log!("Relay origin route lookup failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_iota_not_found", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorNoIota) - .await; - return; - } - }; - let forwarded = match forward_verified_relay(&frame, RouteTarget::Iota(router)) { - Ok(value) => value, - Err(error) => { - log!("Relay origin forwarding validation failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "forwarding_validation_failed", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - let bytes = match forwarded.to_bytes() { - Ok(bytes) => bytes, - Err(error) => { - log!("Relay origin retry could not be serialized: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "serialization_failed", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let relay_identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id.clone(), - }, - _ => { - log!("Relay identity exceeds the storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = - outgoing_relay::commit_outgoing_relay(outgoing_relay::OutgoingRelay { - target: RouteTarget::Iota(router), - identity: &relay_identity, - frame: &bytes, - created_at: now_millis_i64(), - frame_id, - type_map_version: &type_map_version, - }) - { - log!("Relay origin retry queue failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "queue_failed", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - match self - .await_relay_response(&forwarded, Duration::from_secs(20)) - .await - { - Ok(response) if response.is_type(CommunicationType::Success) => { - let returned_id = response.get_data(DataType::RelayMessageId).as_str(); - let destination_accepted_at = response - .get_data(DataType::RelayAcceptedAt) - .as_number() - .and_then(|value| i64::try_from(value).ok()); - if returned_id != Some(verified.context.message_id.as_str()) { - log!("Relay acknowledgement returned a different RelayMessageId"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - let Some(destination_accepted_at) = destination_accepted_at else { - log!("Relay acknowledgement is missing RelayAcceptedAt"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - let Ok(signer_id) = i64::try_from(verified.context.signer_id) else { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - if let Err(error) = - iota_storage::util::downstream_relay::acknowledge_iota_delivery( - router, - frame_id, - signer_id, - &verified.context.message_id, - destination_accepted_at, - ) - { - log!( - "Relay destination acknowledgement storage failed: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - let response = response - .add_typed_default( - DataType::OriginIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - .add_typed_default( - DataType::DestinationIotaReceivedAt, - DataValue::SignedNumber(destination_accepted_at.into()), - ) - .with_id(frame_id); - if let Err(error) = self.send_message(&response).await { - log!("Relay response could not be sent: {}", error); - } - } - Ok(response) => { - log!("Relay origin route returned {}", response.get_type()); - if response.is_type(CommunicationType::ErrorInternal) { - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_internal_error", - ); - } else if let Ok(signer_id) = i64::try_from(verified.context.signer_id) { - if let Err(error) = - iota_storage::util::downstream_relay::reject_iota_delivery( - router, - frame_id, - signer_id, - &verified.context.message_id, - "destination_rejected", - ) - { - log!("Relay rejection cleanup failed: {}", error); - } - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_rejected", - ); - } - self.send_relay_response( - frame.id(), - response - .get_comm_type_enum() - .unwrap_or(CommunicationType::ErrorInternal), - ) - .await; - } - Err(error) => { - log!("Relay origin forwarding failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_unreachable", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - } - } - return; - } - - let destination = verified.context.final_recipient_id; - let forwarded = match forward_verified_relay(&frame, RouteTarget::User(destination)) { - Ok(value) => value, - Err(error) => { - log!("Relay forwarding validation failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - let bytes = match forwarded.to_bytes() { - Ok(bytes) => bytes, - Err(error) => { - log!( - "Relay could not be serialized for client delivery: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let relay_identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id.clone(), - }, - _ => { - log!("Relay identity exceeds the storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = relay_queue::enqueue( - RouteTarget::User(destination), - &relay_identity, - &bytes, - now_millis_i64(), - frame_id, - &type_map_version, - ) { - log!("Relay could not be queued for client delivery: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - - if !already_applied { - let content = match open_verified_relay_content( - &verified, - &[&keyring], - verified.context.final_recipient_id, - ) { - Ok(value) => value, - Err(error) => { - log!("Relay content verification failed: {}", error); - if let Err(queue_error) = - relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) - { - log!( - "Relay invalid-content queue cleanup failed: {}", - queue_error - ); - } - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - match i64::try_from(destination) { - Ok(value) => value, - Err(_) => { - log!("Relay destination ID exceeds storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }, - false, - ) { - log!("Relay application dispatch failed: {}", error); - if let Err(queue_error) = - relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) - { - log!("Relay application queue cleanup failed: {}", queue_error); - } - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - if let Err(error) = - relay_replay::mark_applied(verified.context.signer_id, &verified.context.message_id) - { - log!("Relay application state update failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if signer_is_local && recipient_is_local { - let Ok(owner) = i64::try_from(verified.context.signer_id) else { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - if let Err(error) = outgoing_relay::apply_outgoing_history_policy( - owner, - owner, - &verified.context.message_id, - ) { - log!("Shared-Iota outgoing history policy failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if let Err(error) = - relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id) - { - log!("Relay queue state update failed: {}", error); - } - self.send_relay_success( - frame.id(), - local_iota_id, - &verified.context.message_id, - accepted_at, - signer_is_local, - ) - .await; - if let Err(error) = self.send_message(&forwarded).await { - log!("Relay delivery to local client failed: {}", error); - } - } - - async fn classify_legacy_pending_relays(&self) { - let mut after_id = 0; - loop { - let records = match relay_queue::list_without_relay_identity_after(after_id, 100) { - Ok(records) => records, - Err(error) => { - log!("Pending relay ownership query failed: {}", error); - return; - } - }; - if records.is_empty() { - return; - } - for record in records { - after_id = record.id; - let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else { - log!( - "Deleting pending Relay {} with invalid type-map version", - record.id - ); - let _ = relay_queue::delete(record.id); - continue; - }; - let type_map = mtp::codec::TypeMap::new(version); - let Ok(frame) = CommunicationValue::from_bytes_with(&record.frame, &type_map) - else { - log!("Deleting pending Relay {} with invalid frame", record.id); - let _ = relay_queue::delete(record.id); - continue; - }; - let Some(local_iota_id) = CONFIG.load().iota_id else { - return; - }; - let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { - return; - }; - let verified = verify_relay_metadata( - &frame, - local_iota_id, - &keyring, - |signer_id| async move { self.resolve_relay_signing_keys(signer_id).await }, - ) - .await; - let verified = match verified { - Ok(verified) => verified, - Err(RelayValidationError::KeyLookup(error)) => { - log!( - "Deferring pending Relay {} ownership lookup: {}", - record.id, - error - ); - continue; - } - Err(RelayValidationError::MissingSigningKeys(signer_id)) => { - log!( - "Deferring pending Relay {} until signer {} keys are available", - record.id, - signer_id - ); - continue; - } - Err(error) => { - log!( - "Deleting structurally invalid pending Relay {}: {}", - record.id, - error - ); - let _ = relay_queue::delete(record.id); - continue; - } - }; - let identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id, - }, - _ => { - let _ = relay_queue::delete(record.id); - continue; - } - }; - if let Err(error) = relay_queue::set_relay_identity(record.id, &identity) { - log!( - "Pending Relay {} ownership backfill failed: {}", - record.id, - error - ); - } - } - } - } - - async fn flush_pending_relays(&self) { - let Ok(records) = relay_queue::list(100) else { - return; - }; - for record in records { - if record.relay_signer_id.is_none() - || record.relay_destination_user_id.is_none() - || record.relay_message_id.is_none() - { - log!( - "Skipping pending Relay {} until ownership is classified", - record.id - ); - continue; - } - let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else { - log!( - "Retaining pending Relay {} with invalid type-map version {}", - record.id, - record.type_map_version - ); - continue; - }; - let type_map = mtp::codec::TypeMap::new(version); - let Ok(frame) = CommunicationValue::from_bytes_with(&record.frame, &type_map) else { - log!("Retaining pending Relay {} with invalid frame", record.id); - continue; - }; - if record.relay_signer_id.is_none() - || record.relay_destination_user_id.is_none() - || record.relay_message_id.is_none() - { - let Some(local_iota_id) = CONFIG.load().iota_id else { - log!( - "Retaining pending Relay {} until the Iota identity is available", - record.id - ); - continue; - }; - let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { - log!( - "Retaining pending Relay {} until the Iota keyring is available", - record.id - ); - continue; - }; - let resolver_connection = self; - let verified = verify_relay_metadata( - &frame, - local_iota_id, - &keyring, - move |signer_id| async move { - resolver_connection - .resolve_relay_signing_keys(signer_id) - .await - }, - ) - .await; - let Ok(verified) = verified else { - log!("Deleting unverifiable pending Relay {}", record.id); - if let Err(error) = relay_queue::delete(record.id) { - log!("Pending Relay {} cleanup failed: {}", record.id, error); - } - continue; - }; - let relay_identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id, - }, - _ => { - log!( - "Deleting pending Relay {} with an out-of-range identity", - record.id - ); - if let Err(error) = relay_queue::delete(record.id) { - log!("Pending Relay {} cleanup failed: {}", record.id, error); - } - continue; - } - }; - if let Err(error) = relay_queue::set_relay_identity(record.id, &relay_identity) { - log!( - "Pending Relay {} ownership backfill failed: {}", - record.id, - error - ); - continue; - } - } - let Ok(forwarded) = forward_verified_relay(&frame, record.target) else { - log!( - "Retaining pending Relay {} with invalid route target", - record.id - ); - continue; - }; - match record.target { - RouteTarget::Iota(destination_iota) => { - match self - .await_relay_response(&forwarded, Duration::from_secs(20)) - .await - { - Ok(response) if response.is_type(CommunicationType::Success) => { - let accepted_at = response - .get_data(DataType::RelayAcceptedAt) - .as_number() - .and_then(|value| i64::try_from(value).ok()); - let relay_id = response.get_data(DataType::RelayMessageId).as_str(); - if let (Some(accepted_at), Some(message_id), Some(signer_id)) = ( - accepted_at, - record.relay_message_id.as_deref(), - record.relay_signer_id, - ) && relay_id == Some(message_id) - { - if let Err(error) = - iota_storage::util::downstream_relay::acknowledge_iota_delivery( - destination_iota, - record.frame_id, - signer_id, - message_id, - accepted_at, - ) - { - log!( - "Pending Relay {} acknowledgement failed: {}", - record.id, - error - ); - } - } else { - log!("Pending Relay {} returned malformed Success", record.id); - } - } - Ok(response) if !response.is_type(CommunicationType::ErrorInternal) => { - if let (Some(message_id), Some(signer_id)) = - (record.relay_message_id.as_deref(), record.relay_signer_id) - { - if let Err(error) = - iota_storage::util::downstream_relay::reject_iota_delivery( - destination_iota, - record.frame_id, - signer_id, - message_id, - "destination_rejected", - ) - { - log!( - "Pending Relay {} rejection cleanup failed: {}", - record.id, - error - ); - } - } - } - Ok(response) => log!( - "Pending Relay {} route returned retryable {}", - record.id, - response.get_type() - ), - Err(error) => { - log!("Pending Relay {} delivery failed: {}", record.id, error) - } - } - } - RouteTarget::User(_) => { - if let Err(error) = self.send_message(&forwarded).await { - log!("Pending Relay {} delivery failed: {}", record.id, error); - } - } - } - } - } - - // ------------------------------------------------------------------------- - // Message Handling - Dispatch - // ------------------------------------------------------------------------- - - pub async fn handle_message(self: Arc, cv: CommunicationValue) { - log_cv_in!(&cv); - - if cv.is_type(CommunicationType::Success) - && let Some(frame_id) = cv.id() - && let Some(destination_id) = cv - .get_data(DataType::UserId) - .as_number() - .and_then(|value| u64::try_from(value).ok()) - { - let Ok(destination_id) = i64::try_from(destination_id) else { - log!("Relay destination ID exceeds storage range"); - return; - }; - match client_relay_delivery::acknowledge_client_delivery(destination_id, frame_id) { - Ok(client_relay_delivery::ClientRelayDeliveryResult::Acknowledged) => { - return; - } - Ok(client_relay_delivery::ClientRelayDeliveryResult::NotFound) => {} - Err(error) => log!("Relay delivery acknowledgement failed: {}", error), - } - } - - let Some(msg_id) = cv.id() else { - self.handle_message_impl(cv).await; - return; - }; - - if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { - if (task.task)(cv.clone()) { - return; - } - } - - self.clone().handle_message_impl(cv).await; - } - - async fn handle_message_impl(self: Arc, cv: CommunicationValue) { - if cv.is_type(CommunicationType::Relay) { - self.handle_relay(cv).await; - return; - } - if cv.require_id().is_err() { - let _ = self - .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - if matches!( - iota_connection::relay::message_security_class(&cv), - iota_connection::relay::MessageSecurityClass::RelayOnly - ) { - log!("Rejecting sender-based application mutation outside Relay"); - let _ = self - .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - macro_rules! dispatch { - ($ty:ident, $method:ident) => { - if cv.is_type(CommunicationType::$ty) { - self.clone().$method(&cv).await; - return; - } - }; - } - - dispatch!(GetChatSecret, handle_get_chat_secret); - dispatch!(AppIdentification, handle_app_identification); - dispatch!(AppChallengeResponse, handle_app_challenge_response); - dispatch!(SaveAppData, handle_save_app_data); - dispatch!(LoadAppData, handle_load_app_data); - dispatch!(CreateApp, handle_create_app); - dispatch!(DeleteApp, handle_delete_app); - dispatch!(ClientConnected, handle_client_connected); - dispatch!(ClientStateAck, handle_client_state_ack); - dispatch!(MessageEdit, handle_message_edit); - dispatch!(MessageEditLive, handle_message_edit_live); - dispatch!(MessageReactionAdd, handle_message_reaction_add); - dispatch!(MessageReactionRemove, handle_message_reaction_remove); - dispatch!(MessageReactionLive, handle_message_reaction_live); - dispatch!(MessageDeleteLive, handle_message_delete_live); - dispatch!(MessageGet, handle_message_get); - dispatch!(MessagesGet, handle_messages_get); - dispatch!(GetChats, handle_get_chats); - dispatch!(AddCommunity, handle_add_community); - dispatch!(GetCommunities, handle_get_communities); - dispatch!(RemoveCommunity, handle_remove_community); - dispatch!(GlobalSettingsSave, handle_global_settings_save); - dispatch!(GlobalSettingsLoad, handle_global_settings_load); - dispatch!(SettingsSave, handle_settings_save); - dispatch!(SettingsLoad, handle_settings_load); - dispatch!(SettingsList, handle_settings_list); - dispatch!(SyncedSettingSet, handle_synced_setting_set); - dispatch!(SyncedSettingGet, handle_synced_setting_get); - dispatch!(SyncedSettingDelete, handle_synced_setting_delete); - dispatch!(SyncedSettingsList, handle_synced_settings_list); - dispatch!(UserBlobPut, handle_user_blob_put); - dispatch!(UserBlobGet, handle_user_blob_get); - dispatch!(UserBlobDelete, handle_user_blob_delete); - dispatch!(UserBlobList, handle_user_blob_list); - dispatch!(UserBlock, handle_user_block); - dispatch!(UserUnblock, handle_user_unblock); - dispatch!(BlockedUsersGet, handle_blocked_users_get); - dispatch!(ReceiptPolicyGet, handle_receipt_policy_get); - dispatch!(ReceiptPolicySet, handle_receipt_policy_set); - dispatch!(MessageStoragePolicyGet, handle_message_storage_policy_get); - dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set); - dispatch!(UserBlockCheck, handle_user_block_check); - dispatch!(EraseHostedUserData, handle_erase_hosted_user_data); - } - - // ------------------------------------------------------------------------- - // Message Handlers - // ------------------------------------------------------------------------- - - /// Omega-authorized account cleanup. The storage operation is idempotent; - /// acknowledgement is therefore safe to retry after a reconnect. - async fn handle_erase_hosted_user_data(self: Arc, cv: &CommunicationValue) { - let Some(user_id) = cv - .get_data(DataType::UserId) - .as_signed_number() - .and_then(|id| i64::try_from(id).ok()) - .filter(|id| *id > 0) - else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - if iota_storage::users::user_manager::erase_user_locally(user_id).is_err() { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - - let acknowledgement = CommunicationValue::new(CommunicationType::EraseHostedUserDataAck) - .with_request_id(cv) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); - let _ = self.send_message(&acknowledgement).await; - } - - async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_get_chat_secret(cv)) - .await; - } - - async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - let Some(user_id) = data_i64(cv, DataType::UserId).filter(|id| *id > 0) else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - let mut trusted = false; - let user = match iota_storage::users::user_manager::get_user(user_id) { - Ok(user) => user, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - if let Some(user) = user { - if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { - if pub_k == &app_public_key { - trusted = true; - } - } - } - - if trusted { - let challenge = Uuid::new_v4().to_string(); - - self.app_challenges.insert(sender_id, challenge.clone()); - self.app_sessions - .insert(sender_id, (user_id, app_identifier.clone())); - - if let Some(app_pub_bundle) = - iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) - { - let keyring = self.keyring.read().await.as_ref().cloned(); - if let Some(keyring) = keyring { - if let Ok(encrypted_challenge) = - crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) - { - let bundle = keyring.public_key_bundle(); - let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - - let res = CommunicationValue::new(CommunicationType::AppChallenge) - .with_request_id(cv) - .with_receiver(sender_id) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) - .add_typed_default( - DataType::Challenge, - DataValue::Str(encrypted_challenge), - ); - - let _ = self.send_message(&res).await; - return; - } - } - } - } - - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - } - - async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { - if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { - if expected_challenge == *response { - let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - return; - } - } - } - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - } - - async fn handle_save_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let app_data = cv - .get_data(DataType::AppData) - .as_str() - .unwrap_or("") - .to_string(); - - if let Some(session) = self.app_sessions.get(&sender_id) { - let (user_id, app_identifier) = session.value(); - iota_storage::users::user_manager::save_app_data(*user_id, app_identifier, &app_data); - } - - let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - } - - async fn handle_load_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let mut app_data = String::new(); - - if let Some(session) = self.app_sessions.get(&sender_id) { - let (user_id, app_identifier) = session.value(); - app_data = iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); - } - - let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_request_id(cv) - .with_receiver(sender_id) - .add_typed_default(DataType::AppData, DataValue::Str(app_data)); - let _ = self.send_message(&res).await; - } - - async fn handle_create_app(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_create_app(cv)) - .await; - } - - async fn handle_delete_app(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_delete_app(cv)) - .await; - } - - async fn handle_client_connected(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_client_connected(cv)) - .await; - } - - async fn handle_client_state_ack(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_client_state_ack(cv)) - .await; - } - - fn mutation_live_message( - ty: CommunicationType, - request: &CommunicationValue, - mutation: &message_handlers::MessageMutation, - extra: Vec<(DataType, DataValue)>, - ) -> CommunicationValue { - let mut message = CommunicationValue::new(ty) - .with_request_id(request) - .with_sender(wire_user_id(mutation.sender_id)) - .with_receiver(wire_user_id(mutation.partner_id)) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(mutation.sender_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(mutation.send_time as i128), - ); - for (data_type, value) in extra { - message = message.add_typed_default(data_type, value); - } - message - } - - async fn persist_and_deliver_remote_edit(&self, cv: &CommunicationValue) { - let sender_id = match cv - .require_sender() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(sender_id) => sender_id, - None => return, - }; - let receiver_id = match cv - .require_receiver() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(receiver_id) if receiver_id > 0 => receiver_id, - _ => return, - }; - let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { - return; - }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { - return; - }; - if chat_files::apply_remote_edit(receiver_id, sender_id, send_time, sender_id, content) - .is_ok() - { - let _ = self.send_message(cv).await; - } - } - - async fn persist_and_deliver_remote_reaction(&self, cv: &CommunicationValue, add: bool) { - let sender_id = match cv - .require_sender() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(sender_id) => sender_id, - None => return, - }; - let receiver_id = match cv - .require_receiver() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(receiver_id) if receiver_id > 0 => receiver_id, - _ => return, - }; - let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { - return; - }; - let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { - return; - }; - if reaction.is_empty() || reaction.len() > 64 { - return; - } - let result = if add { - chat_files::add_reaction(receiver_id, sender_id, send_time, sender_id, reaction) - } else { - chat_files::remove_reaction(receiver_id, sender_id, send_time, sender_id, reaction) - }; - if result.is_ok() { - let _ = self.send_message(cv).await; - } - } - - async fn persist_and_deliver_remote_delete(&self, cv: &CommunicationValue) { - let sender_id = match cv - .require_sender() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(sender_id) => sender_id, - None => return, - }; - let receiver_id = match cv - .require_receiver() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(receiver_id) if receiver_id > 0 => receiver_id, - _ => return, - }; - let Some(send_time) = data_i64(cv, DataType::SendTime).filter(|time| *time > 0) else { - return; - }; - if chat_files::apply_remote_delete(receiver_id, sender_id, send_time, sender_id).is_ok() { - let _ = self.send_message(cv).await; - } - } - - async fn handle_message_edit(self: Arc, cv: &CommunicationValue) { - let response = message_handlers::handle_message_edit(cv); - if !response.is_type(CommunicationType::Success) { - let _ = self.send_message(&response).await; - return; - } - let Ok(mutation) = message_handlers::message_mutation(cv) else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let Some(content) = cv.get_data(DataType::Content).as_str() else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let live = Self::mutation_live_message( - CommunicationType::MessageEditLive, - cv, - &mutation, - vec![(DataType::Content, DataValue::Str(content.to_string()))], - ); - let partner_is_local = - match iota_storage::users::user_manager::get_user(mutation.partner_id) { - Ok(user) => user.is_some(), - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - if partner_is_local - && chat_files::apply_remote_edit( - mutation.partner_id, - mutation.sender_id, - mutation.send_time, - mutation.sender_id, - content, - ) - .is_err() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - let _ = self.send_message(&response).await; - let _ = self.send_message(&live).await; - } - - async fn handle_message_edit_live(self: Arc, cv: &CommunicationValue) { - self.persist_and_deliver_remote_edit(cv).await; - } - - async fn handle_message_reaction_add(self: Arc, cv: &CommunicationValue) { - self.handle_message_reaction(cv, true).await; - } - - async fn handle_message_reaction_remove(self: Arc, cv: &CommunicationValue) { - self.handle_message_reaction(cv, false).await; - } - - async fn handle_message_reaction(self: Arc, cv: &CommunicationValue, add: bool) { - let response = message_handlers::handle_message_reaction(cv, add); - if !response.is_type(CommunicationType::Success) { - let _ = self.send_message(&response).await; - return; - } - let Ok(mutation) = message_handlers::message_mutation(cv) else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let Some(reaction) = cv.get_data(DataType::Reaction).as_str() else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let live = Self::mutation_live_message( - CommunicationType::MessageReactionLive, - cv, - &mutation, - vec![ - (DataType::Reaction, DataValue::Str(reaction.to_string())), - ( - DataType::SenderId, - DataValue::SignedNumber(mutation.sender_id as i128), - ), - (DataType::Accepted, DataValue::Bool(add)), - ], - ); - let partner_is_local = - match iota_storage::users::user_manager::get_user(mutation.partner_id) { - Ok(user) => user.is_some(), - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - if partner_is_local { - let result = if add { - chat_files::add_reaction( - mutation.partner_id, - mutation.sender_id, - mutation.send_time, - mutation.sender_id, - reaction, - ) - } else { - chat_files::remove_reaction( - mutation.partner_id, - mutation.sender_id, - mutation.send_time, - mutation.sender_id, - reaction, - ) - }; - if result.is_err() { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - } - let _ = self.send_message(&response).await; - let _ = self.send_message(&live).await; - } - - async fn handle_message_reaction_live(self: Arc, cv: &CommunicationValue) { - let add = cv.get_data(DataType::Accepted).as_bool().unwrap_or(true); - self.persist_and_deliver_remote_reaction(cv, add).await; - } - - async fn handle_message_delete_live(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv - .require_sender() - .ok() - .and_then(|id| i64::try_from(id).ok()) - { - Some(sender_id) => sender_id, - None => return, - }; - let sender_is_local = match iota_storage::users::user_manager::get_user(sender_id) { - Ok(user) => user.is_some(), - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - if !sender_is_local { - self.persist_and_deliver_remote_delete(cv).await; - return; - } - - let response = message_handlers::handle_message_delete(cv); - if !response.is_type(CommunicationType::Success) { - let _ = self.send_message(&response).await; - return; - } - let Ok(mutation) = message_handlers::message_mutation(cv) else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - let live = Self::mutation_live_message( - CommunicationType::MessageDeleteLive, - cv, - &mutation, - Vec::new(), - ); - let partner_is_local = - match iota_storage::users::user_manager::get_user(mutation.partner_id) { - Ok(user) => user.is_some(), - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - if partner_is_local - && chat_files::apply_remote_delete( - mutation.partner_id, - mutation.sender_id, - mutation.send_time, - mutation.sender_id, - ) - .is_err() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - let _ = self.send_message(&response).await; - let _ = self.send_message(&live).await; - } - - async fn handle_messages_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_messages_get(cv)) - .await; - } - - async fn handle_message_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_message_get(cv)) - .await; - } - - async fn handle_get_chats(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_get_chats(cv)) - .await; - } - - async fn handle_add_community(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_add_community(cv)) - .await; - } - - async fn handle_get_communities(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_get_communities(cv)) - .await; - } - - async fn handle_remove_community(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_remove_community(cv)) - .await; - } - - async fn handle_global_settings_save(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_global_settings_save(cv)) - .await; - } - - async fn handle_global_settings_load(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_global_settings_load(cv)) - .await; - } - - async fn handle_settings_save(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_settings_save(cv, 0)) - .await; - } - - async fn handle_settings_load(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_settings_load(cv, 0)) - .await; - } - - async fn handle_settings_list(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_settings_list(cv, 0)) - .await; - } - - async fn handle_synced_setting_set(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_synced_setting_set(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_synced_setting_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_synced_setting_get(cv)) - .await; - } - - async fn handle_synced_setting_delete(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_synced_setting_delete(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_synced_settings_list(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_synced_settings_list(cv)) - .await; - } - - async fn handle_user_blob_put(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_user_blob_put(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_user_blob_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_user_blob_get(cv)) - .await; - } - - async fn handle_user_blob_delete(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_user_blob_delete(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_user_blob_list(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_user_blob_list(cv)) - .await; - } - - async fn handle_user_block(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_user_block(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_user_unblock(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_user_unblock(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_blocked_users_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_blocked_users_get(cv)) - .await; - } - - async fn handle_receipt_policy_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_receipt_policy_get(cv)) - .await; - } - - async fn handle_receipt_policy_set(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_receipt_policy_set(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_message_storage_policy_get(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_message_storage_policy_get(cv)) - .await; - } - - async fn handle_message_storage_policy_set(self: Arc, cv: &CommunicationValue) { - let mutation = message_handlers::handle_message_storage_policy_set(cv); - let _ = self.send_message(&mutation.response).await; - if let Some(changed) = mutation.changed { - let _ = self.send_message(&changed).await; - } - } - - async fn handle_user_block_check(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_user_block_check(cv)) - .await; - } - - // ------------------------------------------------------------------------- - // Public API - // ------------------------------------------------------------------------- - - pub async fn send_message(&self, cv: &CommunicationValue) -> Result<(), String> { - let sender_guard = self.sender.read().await; - if let Some(sender) = sender_guard.as_ref() { - if !sender.is_open() { - drop(sender_guard); - if let Some(sender) = self.sender.write().await.take() { - sender.close().await; - } - self.fail_all_waiting_tasks(format!( - "Send failed: connection closed (connection_id={})", - self.connection_id - )) - .await; - return Err("connection closed".to_string()); - } - - let sender_clone = Arc::clone(sender); - drop(sender_guard); - - log_cv_out!(&cv); - - if let Err(e) = sender_clone.send(cv).await { - self.fail_all_waiting_tasks(format!( - "Send failed: {} (connection_id={})", - e, self.connection_id - )) - .await; - return Err(e.to_string()); - } - - Ok(()) - } else { - Err("not connected".to_string()) - } - } - - async fn fail_all_waiting_tasks(&self, reason: String) { - let keys: Vec = WAITING_TASKS.iter().map(|entry| *entry.key()).collect(); - - for key in keys { - if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { - let response = CommunicationValue::new(CommunicationType::ErrorInternal) - .with_id(key) - .add_typed_default(DataType::Message, DataValue::Str(reason.clone())); - let _ = (waiting_task.task)(response); - } - } - } - - pub async fn is_connected(&self) -> bool { - self.state.read().await.is_connected() - } - - pub async fn is_identified(&self) -> bool { - self.state.read().await.is_identified() - } - - pub async fn await_response( - &self, - cv: &CommunicationValue, - timeout_duration: Option, - ) -> Result { - let (tx, rx) = oneshot::channel(); - let msg_id = cv - .require_id() - .map_err(|error| format!("cannot await response without a message id: {error}"))?; - - WAITING_TASKS.insert( - msg_id, - WaitingTask { - task: Box::new(move |response_cv| { - let _ = tx.send(response_cv); - true - }), - inserted_at: Instant::now(), - }, - ); - - if let Err(send_err) = self.send_message(cv).await { - WAITING_TASKS.remove(&msg_id); - return Err(format!( - "Request send failed (msg_id={}, reason={})", - msg_id, send_err - )); - } - - let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); - - match tokio::time::timeout(timeout, rx).await { - Ok(Ok(response_cv)) => { - let is_error = response_cv.is_type(CommunicationType::Error) - || response_cv.is_type(CommunicationType::ErrorInternal) - || response_cv.is_type(CommunicationType::ErrorNotFound) - || response_cv.is_type(CommunicationType::ErrorInvalidData) - || response_cv.is_type(CommunicationType::ErrorInvalidChallenge) - || response_cv.is_type(CommunicationType::ErrorNotAuthenticated); - if is_error { - let reason = response_cv - .get_data(DataType::Message) - .as_str() - .or_else(|| response_cv.get_data(DataType::ErrorType).as_str()) - .unwrap_or("connection error") - .to_string(); - Err(format!( - "Request rejected (msg_id={}, reason={})", - msg_id, reason - )) - } else { - Ok(response_cv) - } - } - Ok(Err(_)) => { - WAITING_TASKS.remove(&msg_id); - Err("Channel closed while awaiting response".to_string()) - } - Err(_) => { - let waiting_tasks_len = WAITING_TASKS.len(); - WAITING_TASKS.remove(&msg_id); - Err(format!( - "Request timed out (msg_id={}, timeout={}s, connected={}, waiting_tasks={})", - msg_id, - timeout.as_secs(), - self.is_connected().await, - waiting_tasks_len - )) - } - } - } - - async fn await_relay_response( - &self, - cv: &CommunicationValue, - timeout: Duration, - ) -> Result { - let (tx, rx) = oneshot::channel(); - let msg_id = cv.require_id().map_err(|error| error.to_string())?; - WAITING_TASKS.insert( - msg_id, - WaitingTask { - task: Box::new(move |response| { - let _ = tx.send(response); - true - }), - inserted_at: Instant::now(), - }, - ); - if let Err(error) = self.send_message(cv).await { - WAITING_TASKS.remove(&msg_id); - return Err(format!("Relay send failed: {error}")); - } - match tokio::time::timeout(timeout, rx).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(_)) => { - WAITING_TASKS.remove(&msg_id); - Err("Relay response channel closed".into()) - } - Err(_) => { - WAITING_TASKS.remove(&msg_id); - Err("Relay response timed out".into()) - } - } - } - - pub async fn await_connection(&self, timeout_duration: Option) -> Result<(), String> { - let mut rx = self.state_watch_tx.subscribe(); - if rx.borrow().is_connected() { - return Ok(()); - } - - let timeout = timeout_duration.unwrap_or(CONNECTION_TIMEOUT); - - let result: Result<(), String> = tokio::time::timeout(timeout, async { - loop { - rx.changed() - .await - .map_err(|_| "State watch channel closed".to_string())?; - if rx.borrow().is_connected() { - return Ok(()); - } - } - }) - .await - .map_err(|_| { - format!( - "Connection not established within {} seconds", - timeout.as_secs() - ) - })?; - - result - } - - pub async fn has_auth_failure(&self) -> bool { - self.auth_failure.read().await.is_some() - } - - pub async fn get_auth_failure(&self) -> Option { - self.auth_failure.read().await.clone() - } - - pub async fn clear_auth_failure(&self) { - *self.auth_failure.write().await = None; - } - - pub async fn reconnect(self: &Arc) { - self.clear_auth_failure().await; - *self.reconnect_on_close.write().await = true; - self.stop().await; - self.connect().await; - } - - /// Create a new local keyring and register it as a new Iota identity. - /// The existing keyring is retained as a timestamped backup so a failed - /// recovery does not silently destroy the user's previous identity. - pub async fn rotate_identity(self: &Arc) -> Result<(), OmikronError> { - log!("Iota identity rotation requested"); - self.stop().await; - - let path = identity_path(); - if path.exists() { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let backup = path.with_extension(format!("mk.backup-{stamp}")); - std::fs::rename(path, &backup).map_err(|error| { - OmikronError::Internal(format!( - "could not back up identity {}: {error}", - path.display() - )) - })?; - log!("Existing Iota identity backed up to {}", backup.display()); - } - - let keyring = crypto_helper::generate_keyring(); - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - std::fs::create_dir_all(parent).map_err(|error| { - OmikronError::Internal(format!( - "could not create identity directory {}: {error}", - parent.display() - )) - })?; - } - save_keyring_verified(&keyring, path).map_err(|error| { - OmikronError::Internal(format!( - "could not save new identity {}: {error}", - path.display() - )) - })?; - modify_config(|config| { - config.iota_id = None; - config.keyring = None; - config.public_key = None; - config.private_key = None; - }); - log!("New Iota identity generated; registration started"); - - self.clear_auth_failure().await; - self.connect().await; - match self.await_connection(Some(CONNECTION_TIMEOUT)).await { - Ok(()) => { - let id = CONFIG.load().iota_id; - log!( - "New Iota identity registered{}", - id.map(|v| format!(" (Iota-ID: {v})")).unwrap_or_default() - ); - Ok(()) - } - Err(timeout) => { - if let Some(reason) = self.get_auth_failure().await { - log!("Iota identity registration failed: {}", reason); - Err(OmikronError::Authentication(reason)) - } else { - log!("Iota identity registration did not complete: {}", timeout); - Err(OmikronError::Timeout(timeout)) - } - } - } - } -} - -// ============================================================================ -// Global Instance -// ============================================================================ - -pub async fn connect_initial( - cancellation: CancellationToken, - active_tasks: Arc>, - app: Arc>, -) -> Result, crate::client::OmikronStartupError> { - let conn = Arc::new(OmikronConnection::with_cancellation( - cancellation, - active_tasks, - app, - )); - conn.connect().await; - match conn.await_connection(Some(CONNECTION_TIMEOUT)).await { - Ok(()) => Ok(conn), - Err(_) if conn.has_auth_failure().await => { - Err(crate::client::OmikronStartupError::Authentication { connection: conn }) - } - Err(_) => { - Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn }) - } - } -} - -impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { - async fn send_message(&self, cv: &CommunicationValue) -> Result<(), String> { - OmikronConnection::send_message(self, cv).await - } - - async fn await_response( - &self, - cv: &CommunicationValue, - timeout: Option, - ) -> Result { - OmikronConnection::await_response(self, cv, timeout).await - } - - async fn is_connected(&self) -> bool { - OmikronConnection::is_connected(self).await - } - - async fn is_identified(&self) -> bool { - OmikronConnection::is_identified(self).await - } - - async fn stop(&self) { - OmikronConnection::stop(self).await - } -} - -#[async_trait::async_trait] -impl OmikronClient for OmikronConnection { - async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError> { - Self::send_message(self, value) - .await - .map_err(OmikronError::Disconnected) - } - - async fn await_response( - &self, - value: &CommunicationValue, - timeout: Duration, - ) -> Result { - Self::await_response(self, value, Some(timeout)) - .await - .map_err(|error| { - if error.contains("timed out") { - OmikronError::Timeout(error) - } else if error.starts_with("Request rejected") { - OmikronError::Internal(error) - } else { - OmikronError::Disconnected(error) - } - }) - } - - async fn reconnect(&self) -> Result<(), OmikronError> { - let this = Arc::new(Self { - state: self.state.clone(), - state_watch_tx: self.state_watch_tx.clone(), - sender: self.sender.clone(), - connection_loop_handle: self.connection_loop_handle.clone(), - last_ping: self.last_ping.clone(), - maintenance_handle: self.maintenance_handle.clone(), - connection_id: self.connection_id, - shutdown_tx: self.shutdown_tx.clone(), - reconnect_on_close: self.reconnect_on_close.clone(), - auth_failure: self.auth_failure.clone(), - keyring: self.keyring.clone(), - app_challenges: self.app_challenges.clone(), - app_sessions: self.app_sessions.clone(), - handler_semaphore: self.handler_semaphore.clone(), - cancellation: self.cancellation.clone(), - active_tasks: self.active_tasks.clone(), - app: self.app.clone(), - }); - Self::reconnect(&this).await; - Ok(()) - } - - async fn rotate_identity(&self) -> Result<(), OmikronError> { - let this = Arc::new(Self { - state: self.state.clone(), - state_watch_tx: self.state_watch_tx.clone(), - sender: self.sender.clone(), - connection_loop_handle: self.connection_loop_handle.clone(), - last_ping: self.last_ping.clone(), - maintenance_handle: self.maintenance_handle.clone(), - connection_id: self.connection_id, - shutdown_tx: self.shutdown_tx.clone(), - reconnect_on_close: self.reconnect_on_close.clone(), - auth_failure: self.auth_failure.clone(), - keyring: self.keyring.clone(), - app_challenges: self.app_challenges.clone(), - app_sessions: self.app_sessions.clone(), - handler_semaphore: self.handler_semaphore.clone(), - cancellation: self.cancellation.clone(), - active_tasks: self.active_tasks.clone(), - app: self.app.clone(), - }); - Self::rotate_identity(&this).await - } - - async fn is_connected(&self) -> bool { - Self::is_connected(self).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_path(name: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "iota-identity-{name}-{}-{}", - std::process::id(), - Uuid::new_v4() - )) - } - - #[test] - fn generated_identity_is_unprotected_and_survives_reload() { - let path = test_path("reload"); - let keyring = load_or_migrate_keyring_at(&path, None).expect("identity saves"); - let reloaded = load_or_migrate_keyring_at(&path, None).expect("identity loads"); - assert_eq!( - keyring.try_to_bytes().expect("keyring serializes"), - reloaded.try_to_bytes().expect("keyring serializes") - ); - assert!(mtp::files::load_keyring_raw(&path).is_ok()); - let _ = fs::remove_file(path); - } - - #[test] - fn corrupt_existing_identity_does_not_generate_a_replacement() { - let path = test_path("corrupt"); - fs::write(&path, b"not a keyring").expect("corrupt fixture writes"); - let error = - load_or_migrate_keyring_at(&path, None).expect_err("corrupt identity must fail"); - assert!(matches!(error, IdentityError::Storage(_))); - let _ = fs::remove_file(path); - } - - #[test] - fn legacy_raw_identity_is_loaded_only_when_the_raw_format_is_valid() { - let path = test_path("legacy"); - let keyring = crypto_helper::generate_keyring(); - let mut raw = b"MTMK".to_vec(); - raw.push(1); - raw.extend_from_slice(&keyring.try_to_bytes().expect("keyring serializes")); - fs::write(&path, raw).expect("legacy fixture writes"); - - let migrated = load_or_migrate_keyring_at(&path, None).expect("legacy identity loads"); - assert_eq!( - migrated.try_to_bytes().expect("keyring serializes"), - keyring.try_to_bytes().expect("keyring serializes") - ); - let _ = fs::remove_file(path); - } - - #[test] - fn identity_directory_failure_is_returned() { - let parent = test_path("parent-file"); - fs::write(&parent, b"not a directory").expect("parent fixture writes"); - let path = parent.join("iota.mk"); - let error = load_or_migrate_keyring_at(&path, None) - .expect_err("directory failure must be returned"); - assert!(matches!(error, IdentityError::Directory(_))); - let _ = fs::remove_file(parent); - } - - #[test] - fn reconnect_jitter_stays_bounded_by_the_exponential_delay_ceiling() { - for _ in 0..32 { - let delay = jittered_reconnect_delay(Duration::from_secs(5)); - assert!(delay >= Duration::from_secs(4)); - assert!(delay <= Duration::from_secs(6)); - } - - assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY); - } -} diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs deleted file mode 100644 index 5c33416..0000000 --- a/omikron-connector/src/user_ops.rs +++ /dev/null @@ -1,666 +0,0 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use iota_logger::{PrintType, log, log_cv, log_t}; -use iota_storage::users::pending_operations::{ - self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, -}; -use iota_storage::users::user_manager::try_add_user; -use iota_storage::users::user_profile::UserProfile; -use iota_storage::util::config_util::CONFIG; -use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; -use iota_util::file_util::{remove_user_credential, write_user_credential}; -use iota_util::mtp_compat::OptionalDataValueExt; -use iota_util::tu::TuCredential; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; -use rand_core::{OsRng, RngCore}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::OmikronClient; -use crate::omega_discovery; - -#[derive(Debug)] -pub enum CreateUserError { - InvalidUsername, - Transport(crate::OmikronError), - InvalidResponse, - RemoteRejected, - LocalFinalizationPending { user_id: i64 }, - LocalPersistence(String), -} - -#[derive(Debug)] -pub enum LifecycleUserError { - InvalidCredential(String), - OmegaHostMismatch, - RemoteRejected, - Transport(crate::OmikronError), - LocalPersistence(String), -} - -impl From for LifecycleUserError { - fn from(value: crate::OmikronError) -> Self { - Self::Transport(value) - } -} - -fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { - let mut payload = Vec::with_capacity(domain.len() + 24); - payload.extend_from_slice(domain); - payload.extend_from_slice(&user_id.to_be_bytes()); - payload.extend_from_slice(&iota_id.to_be_bytes()); - payload.extend_from_slice(&nonce.to_be_bytes()); - payload -} - -fn configured_iota_id() -> Result { - CONFIG - .load() - .iota_id - .and_then(|id| i64::try_from(id).ok()) - .filter(|id| *id > 0) - .ok_or_else(|| { - LifecycleUserError::InvalidCredential("Iota identity is not registered".into()) - }) -} - -fn sign_lifecycle_payload( - credential: &TuCredential, - payload: &[u8], -) -> Result<(Vec, Vec), LifecycleUserError> { - let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? - .sign(payload) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - let pq = MlDsaSigner::new( - &credential.keyring.sig_pq_secret_key, - &credential.keyring.sig_pq_public_key, - ) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? - .sign(payload) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - Ok((classical, pq)) -} - -async fn inspect_credential_account( - connection: &dyn OmikronClient, - credential: &TuCredential, -) -> Result<(String, String, i64), LifecycleUserError> { - if credential.omega_host != omega_discovery::omega_host() { - return Err(LifecycleUserError::OmegaHostMismatch); - } - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::SignedNumber(credential.user_id.into()), - ); - let response = connection - .await_response(&request, Duration::from_secs(20)) - .await?; - if !response.is_type(CommunicationType::GetUserData) { - return Err(LifecycleUserError::RemoteRejected); - } - let username = response - .get_data(DataType::Username) - .as_str() - .map(str::to_owned) - .ok_or(LifecycleUserError::RemoteRejected)?; - let public_key = response - .get_data(DataType::PublicKey) - .as_str() - .map(str::to_owned) - .ok_or(LifecycleUserError::RemoteRejected)?; - let created_at = response - .get_data(DataType::CreatedAt) - .as_signed_number() - .and_then(|value| i64::try_from(value).ok()) - .filter(|value| *value > 0) - .ok_or(LifecycleUserError::RemoteRejected)?; - if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { - return Err(LifecycleUserError::RemoteRejected); - } - Ok((username, public_key, created_at)) -} - -async fn credential_proof( - connection: &dyn OmikronClient, - credential: &TuCredential, - begin: CommunicationType, - challenge: CommunicationType, - complete: CommunicationType, - domain: &[u8], -) -> Result<(), LifecycleUserError> { - let iota_id = configured_iota_id()?; - let begin_request = CommunicationValue::new(begin).add_typed_default( - DataType::UserId, - DataValue::SignedNumber(credential.user_id.into()), - ); - let challenge_response = connection - .await_response(&begin_request, Duration::from_secs(20)) - .await?; - if !challenge_response.is_type(challenge) { - return Err(LifecycleUserError::RemoteRejected); - } - let nonce = challenge_response - .get_data(DataType::ServerNonce) - .as_signed_number() - .and_then(|value| u64::try_from(value).ok()) - .ok_or(LifecycleUserError::RemoteRejected)?; - let (signature, pq_signature) = sign_lifecycle_payload( - credential, - &lifecycle_payload(domain, credential.user_id, iota_id, nonce), - )?; - let complete_request = CommunicationValue::new(complete) - .add_typed_default( - DataType::UserId, - DataValue::SignedNumber(credential.user_id.into()), - ) - .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) - .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) - .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); - let response = connection - .await_response(&complete_request, Duration::from_secs(20)) - .await?; - if response.is_type(CommunicationType::Success) { - Ok(()) - } else { - Err(LifecycleUserError::RemoteRejected) - } -} - -/// Attach or migrate an existing account. Local state is written only after -/// Omega has accepted the credential proof and changed its assignment. -pub async fn attach_user_from_tu( - connection: &dyn OmikronClient, - contents: &str, -) -> Result { - let credential = TuCredential::parse(contents) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - let (username, public_key, created_at) = - inspect_credential_account(connection, &credential).await?; - let profile = UserProfile::new_with_created_at( - credential.user_id, - username, - None, - public_key, - hex_hash(contents), - String::new(), - created_at, - ); - pending_operations::upsert(&PendingUserOperation { - user_id: profile.user_id, - operation: PendingUserOperationKind::Attach, - username: profile.username.clone(), - public_key: Some(profile.public_key.clone()), - private_key_hash: Some(profile.private_key_hash.clone()), - reset_token: Some(profile.reset_token.clone()), - registration_token: None, - phase: PendingUserOperationPhase::Prepared, - created_at: now_millis(), - }) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - write_user_credential(&profile.username, &credential.to_canonical_string()) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - pending_operations::update_phase( - profile.user_id, - PendingUserOperationPhase::CredentialWritten, - ) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - if let Err(error) = credential_proof( - connection, - &credential, - CommunicationType::AttachUserBegin, - CommunicationType::AttachUserChallenge, - CommunicationType::AttachUserComplete, - b"tensamin:user-attach:v1\0", - ) - .await - { - if matches!(error, LifecycleUserError::RemoteRejected) { - let _ = pending_operations::remove(profile.user_id); - let _ = remove_user_credential(profile.user_id, Some(&profile.username)); - } - return Err(error); - } - try_add_user(profile.clone()) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - pending_operations::remove(profile.user_id) - .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - Ok(profile) -} - -pub async fn complete_delete_user_with_tu( - connection: &dyn OmikronClient, - contents: &str, - expected_user_id: i64, -) -> Result<(), LifecycleUserError> { - let credential = TuCredential::parse(contents) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - if credential.user_id != expected_user_id { - return Err(LifecycleUserError::InvalidCredential( - "credential user ID does not match deletion target".into(), - )); - } - inspect_credential_account(connection, &credential).await?; - credential_proof( - connection, - &credential, - CommunicationType::DeleteUserCredentialBegin, - CommunicationType::DeleteUserCredentialChallenge, - CommunicationType::DeleteUserCredentialComplete, - b"tensamin:user-delete:v1\0", - ) - .await -} - -/// Repair local management state after a release or migration committed in -/// Omega but local cleanup was interrupted. Hosted data is retained. -pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { - let Ok(local_iota_id) = configured_iota_id() else { - return; - }; - let pending = match pending_operations::get_all() { - Ok(pending) => pending, - Err(error) => { - log!("Pending user operation reconciliation could not read storage: {error}"); - return; - } - }; - for operation in pending { - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::SignedNumber(operation.user_id.into()), - ); - let response = connection - .await_response(&request, Duration::from_secs(10)) - .await - .ok(); - let remote_iota_id = response.as_ref().and_then(|response| { - response - .get_data(DataType::IotaId) - .as_signed_number() - .and_then(|value| i64::try_from(value).ok()) - }); - let remote_matches = response.as_ref().is_some_and(|response| { - response.is_type(CommunicationType::GetUserData) - && remote_iota_id == Some(local_iota_id) - && response.get_data(DataType::Username).as_str() == Some(&operation.username) - && response.get_data(DataType::PublicKey).as_str() - == operation.public_key.as_deref() - }); - let completion_retried = matches!(operation.operation, PendingUserOperationKind::Create) - && matches!( - operation.phase, - PendingUserOperationPhase::Prepared | PendingUserOperationPhase::CredentialWritten - ) - && !remote_matches - && complete_pending_create(connection, &operation).await; - match operation.operation { - PendingUserOperationKind::Create | PendingUserOperationKind::Attach - if remote_matches || completion_retried => - { - let credential_present = iota_util::file_util::read_user_credential_with_legacy( - operation.user_id, - &operation.username, - ) - .ok() - .flatten() - .is_some(); - if !credential_present { - log!( - "Pending user {} has no credential; leaving it unresolved", - operation.user_id - ); - continue; - } - let Some(public_key) = operation.public_key else { - continue; - }; - let profile = UserProfile::new( - operation.user_id, - operation.username, - None, - public_key, - operation.private_key_hash.unwrap_or_default(), - operation.reset_token.unwrap_or_default(), - ); - if try_add_user(profile).is_ok() { - let _ = pending_operations::remove(operation.user_id); - } - } - PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => { - if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() { - let _ = pending_operations::remove(operation.user_id); - } - } - _ => {} - } - } - for user in iota_storage::users::user_manager::get_users() { - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::SignedNumber(user.user_id.into()), - ); - let Ok(response) = connection - .await_response(&request, Duration::from_secs(10)) - .await - else { - continue; - }; - let remote_iota_id = response - .get_data(DataType::IotaId) - .as_signed_number() - .and_then(|value| i64::try_from(value).ok()); - if remote_iota_id != Some(local_iota_id) { - let _ = iota_storage::users::user_manager::release_user(user.user_id); - } - } -} - -/* - * Retry completion only while the locally persisted operation still owns a - * valid registration lease. Omega treats an exact repeat as idempotent, which - * repairs an interrupted request without allocating another user ID. - */ -async fn complete_pending_create( - connection: &dyn OmikronClient, - operation: &PendingUserOperation, -) -> bool { - let Some(public_key) = operation.public_key.as_ref() else { - return false; - }; - let Some(reset_token) = operation.reset_token.as_ref() else { - return false; - }; - let Some(registration_token) = operation.registration_token.as_ref() else { - return false; - }; - let request = CommunicationValue::new(CommunicationType::CompleteRegisterUser) - .add_typed_default( - DataType::UserId, - DataValue::SignedNumber(operation.user_id.into()), - ) - .add_typed_default( - DataType::Username, - DataValue::Str(operation.username.clone()), - ) - .add_typed_default(DataType::PublicKey, DataValue::Str(public_key.clone())) - .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token.clone())) - .add_typed_default( - DataType::RegisterId, - DataValue::Str(registration_token.clone()), - ); - match connection - .await_response(&request, Duration::from_secs(20)) - .await - { - Ok(response) if response.is_type(CommunicationType::Success) => { - if let Err(error) = pending_operations::update_phase( - operation.user_id, - PendingUserOperationPhase::RemoteCommitted, - ) { - log!( - "Pending user {} completed remotely but could not update its phase: {error}", - operation.user_id - ); - } - true - } - Ok(response) => { - log!( - "Pending user {} registration retry was rejected with {}", - operation.user_id, - response.get_type() - ); - false - } - Err(error) => { - log!( - "Pending user {} registration retry failed: {error}", - operation.user_id - ); - false - } - } -} - -fn valid_username(username: &str) -> bool { - !username.is_empty() - && username.len() <= 15 - && username - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) -} - -fn now_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -async fn request_user_id(connection: &dyn OmikronClient) -> Result<(i64, String), CreateUserError> { - let request = CommunicationValue::new(CommunicationType::GetRegister); - let response = connection - .await_response(&request, Duration::from_secs(20)) - .await - .map_err(CreateUserError::Transport)?; - - if !response.is_type(CommunicationType::GetRegister) { - return Err(CreateUserError::InvalidResponse); - } - - let user_id = response - .get_data(DataType::UserId) - .as_number() - .and_then(|id| i64::try_from(id).ok()) - .filter(|id| (1..(1_i64 << 48)).contains(id)) - .ok_or(CreateUserError::InvalidResponse)?; - let registration_token = response - .get_data(DataType::RegisterId) - .as_str() - .filter(|token| uuid::Uuid::parse_str(token).is_ok()) - .map(str::to_owned) - .ok_or(CreateUserError::InvalidResponse)?; - Ok((user_id, registration_token)) -} - -/// A completion response can be lost after Omega commits the user. Confirm -/// the exact remote record before treating that transport failure as success. -async fn registration_committed(connection: &dyn OmikronClient, profile: &UserProfile) -> bool { - let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::SignedNumber(profile.user_id.into()), - ); - let Ok(response) = connection - .await_response(&request, Duration::from_secs(5)) - .await - else { - return false; - }; - response.get_data(DataType::UserId).as_number() == Some(profile.user_id.into()) - && response.get_data(DataType::Username).as_str() == Some(profile.username.as_str()) - && response.get_data(DataType::PublicKey).as_str() == Some(profile.public_key.as_str()) -} - -pub async fn create_user( - connection: &dyn OmikronClient, - username: &str, -) -> Result { - if !valid_username(username) { - return Err(CreateUserError::InvalidUsername); - } - let (user_id, registration_token) = request_user_id(connection).await?; - log!("User creation: Omega allocated user ID {user_id}"); - let keyring = crypto_helper::generate_keyring(); - let pub_key_bundle = keyring.public_key_bundle(); - let keyring_b64 = crypto_helper::keyring_to_base64(&keyring); - - let private_key_hash = hex_hash(&keyring_b64); - - let mut bytes = [0u8; 192]; - OsRng.fill_bytes(&mut bytes); - let reset_token = STANDARD.encode(&bytes); - - let user_profile = UserProfile::new( - user_id, - username.to_string(), - None, - public_key_bundle_to_base64(&pub_key_bundle), - private_key_hash, - reset_token.clone(), - ); - let credential = format!( - "{}@{}::{}", - user_id, - omega_discovery::omega_host(), - keyring_b64 - ); - pending_operations::upsert(&PendingUserOperation { - user_id, - operation: PendingUserOperationKind::Create, - username: user_profile.username.clone(), - public_key: Some(user_profile.public_key.clone()), - private_key_hash: Some(user_profile.private_key_hash.clone()), - reset_token: Some(user_profile.reset_token.clone()), - registration_token: Some(registration_token.clone()), - phase: PendingUserOperationPhase::Prepared, - created_at: now_millis(), - }) - .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - write_user_credential(username, &credential) - .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - pending_operations::update_phase(user_id, PendingUserOperationPhase::CredentialWritten) - .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; - - let communication_value = CommunicationValue::new(CommunicationType::CompleteRegisterUser) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())) - .add_typed_default(DataType::Username, DataValue::Str(username.to_string())) - .add_typed_default( - DataType::PublicKey, - DataValue::Str(public_key_bundle_to_base64(&pub_key_bundle)), - ) - .add_typed_default(DataType::ResetToken, DataValue::Str(reset_token)) - .add_typed_default(DataType::RegisterId, DataValue::Str(registration_token)); - - let response_communication_value = connection - .await_response(&communication_value, Duration::from_secs(20)) - .await; - - match response_communication_value { - Ok(response) => { - log_cv!(PrintType::Omega, response); - if !response.is_type(CommunicationType::Success) { - let _ = pending_operations::remove(user_id); - let _ = remove_user_credential(user_id, Some(username)); - return Err(CreateUserError::RemoteRejected); - } - } - Err(error) => { - if registration_committed(connection, &user_profile).await { - log!( - "User creation: completion response was lost; verified user {} remotely", - user_id - ); - } else { - log_t!("User creation: {}", error.to_string()); - return Err(match error { - crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected, - error => CreateUserError::Transport(error), - }); - } - } - } - pending_operations::update_phase(user_id, PendingUserOperationPhase::RemoteCommitted) - .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; - try_add_user(user_profile.clone()) - .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; - pending_operations::update_phase(user_id, PendingUserOperationPhase::LocalCommitted) - .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; - pending_operations::remove(user_id) - .map_err(|_| CreateUserError::LocalFinalizationPending { user_id })?; - log!("Created User"); - Ok(user_profile) -} - -#[cfg(test)] -mod tests { - use super::{CreateUserError, request_user_id, valid_username}; - use crate::{OmikronClient, OmikronError}; - use async_trait::async_trait; - use iota_connection::message_common::CommunicationResponseExt; - use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - use std::time::Duration; - - struct RegistrationClient { - response: CommunicationValue, - } - - #[async_trait] - impl OmikronClient for RegistrationClient { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - unreachable!() - } - - async fn await_response( - &self, - request: &CommunicationValue, - _: Duration, - ) -> Result { - assert!(request.is_type(CommunicationType::GetRegister)); - Ok(self.response.clone().with_request_id(request)) - } - - async fn reconnect(&self) -> Result<(), OmikronError> { - unreachable!() - } - - async fn rotate_identity(&self) -> Result<(), OmikronError> { - unreachable!() - } - - async fn is_connected(&self) -> bool { - true - } - } - - #[test] - fn validates_usernames_before_remote_registration() { - assert!(valid_username("alice")); - assert!(valid_username("abc123def456ghi")); - assert!(!valid_username("")); - assert!(!valid_username("sixteen_chars_bad")); - assert!(!valid_username("path/name")); - assert!(!valid_username("upperCase")); - assert!(!valid_username("underscore_name")); - assert!(!valid_username("line\nbreak")); - } - - #[tokio::test] - async fn uses_user_id_allocated_by_omega() { - let client = RegistrationClient { - response: CommunicationValue::new(CommunicationType::GetRegister) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(4_294_967_311)) - .add_typed_default( - DataType::RegisterId, - DataValue::Str("00000000-0000-4000-8000-000000000001".into()), - ), - }; - - assert_eq!( - request_user_id(&client).await.unwrap(), - (4_294_967_311, "00000000-0000-4000-8000-000000000001".into()) - ); - } - - #[tokio::test] - async fn rejects_registration_response_without_a_positive_user_id() { - let client = RegistrationClient { - response: CommunicationValue::new(CommunicationType::GetRegister) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(0)), - }; - - assert!(matches!( - request_user_id(&client).await, - Err(CreateUserError::InvalidResponse) - )); - } -} diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml deleted file mode 100644 index 1fb5337..0000000 --- a/other-iota/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "other-iota" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/other-iota/src/lib.rs b/other-iota/src/lib.rs deleted file mode 100644 index 8b13789..0000000 --- a/other-iota/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 7190a60..0000000 --- a/renovate.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json" -} diff --git a/iota-auth/src/auth_user.rs b/src/auth/auth_user.rs similarity index 100% rename from iota-auth/src/auth_user.rs rename to src/auth/auth_user.rs diff --git a/iota-auth/src/local_auth.rs b/src/auth/local_auth.rs similarity index 91% rename from iota-auth/src/local_auth.rs rename to src/auth/local_auth.rs index f9c0fd1..112ee2b 100644 --- a/iota-auth/src/local_auth.rs +++ b/src/auth/local_auth.rs @@ -1,6 +1,6 @@ use json::JsonValue; -use iota_iota_util::file_util::load_file; +use crate::util::file_util::load_file; // NOT USED AT MOMENT pub fn is_private_key_valid(user_id: &i64, key_hash: &str) -> bool { let file_contents = load_file("", "users.json"); diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..73d6442 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod auth_user; +pub mod local_auth; diff --git a/communities/src/community.rs b/src/communities/community.rs similarity index 90% rename from communities/src/community.rs rename to src/communities/community.rs index 7b23247..835c88a 100644 --- a/communities/src/community.rs +++ b/src/communities/community.rs @@ -40,7 +40,7 @@ impl Community { let mut buf = [0u8; 56]; let mut rng = OsRng; rng.fill_bytes(&mut buf); - let private_key = Secret::from(buf); + let private_key = Secret::from_bytes(&buf).unwrap(); let public_key = PublicKey::from(&private_key); Community { name: String::new(), @@ -58,7 +58,7 @@ impl Community { let mut buf = [0u8; 56]; let mut rng = OsRng; rng.fill_bytes(&mut buf); - let private_key = Secret::from(buf); + let private_key = Secret::from_bytes(&buf).unwrap(); let public_key = PublicKey::from(&private_key); let c = Community { name, @@ -125,7 +125,7 @@ impl Community { self.members.clone() } pub fn get_private_key(&self) -> Secret { - Secret::from(*self.private_key.as_bytes()) + Secret::from_bytes(self.private_key.as_bytes()).unwrap() } pub fn get_public_key(&self) -> &PublicKey { &self.public_key @@ -210,7 +210,7 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - return CommunicationValue::new(CommunicationType::ErrorInternal); + return CommunicationValue::new(CommunicationType::error); } else { // cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161) return interactable.run_function(cv.clone()).await; @@ -222,23 +222,21 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - let Some(category) = interactable.as_any().downcast_ref::() else { - return CommunicationValue::new(CommunicationType::ErrorInternal); - }; + let category: &Category = + interactable.as_any().downcast_ref::().unwrap(); // cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161) return category .get_child(path.to_string(), name.to_string()) - .ok_or(CommunicationValue::new(CommunicationType::ErrorInternal)) - .unwrap_or_else(|error| return error) + .unwrap() .run_function(cv.clone()) .await; } else { - return CommunicationValue::new(CommunicationType::ErrorInternal); + return CommunicationValue::new(CommunicationType::error); } } } } - CommunicationValue::new(CommunicationType::AddConversation) + CommunicationValue::new(CommunicationType::add_conversation) } pub async fn save(&self) { @@ -269,7 +267,7 @@ impl Community { let mut data = JsonValue::new_object(); let mut permissions = JsonValue::new_array(); - for perm in self.permissions.get(user).into_iter().flatten() { + for perm in self.permissions.get(user).unwrap() { if let Ok(_) = permissions.push(perm.to_string()) {} } @@ -289,10 +287,10 @@ impl Community { } pub async fn load(name: &String) -> Option> { let file_contents = file_util::load_file(&format!("communities/{}/", name), "config.json"); - let json_content = json::parse(&file_contents).ok()?; + let json_content = json::parse(&file_contents).unwrap(); let user_data = file_util::load_file(&format!("communities/{}/", name), "users.json"); - let user_json: JsonValue = json::parse(&user_data).ok()?; + let user_json: JsonValue = json::parse(&user_data).unwrap(); let mut users = Vec::new(); let mut permissions: HashMap> = HashMap::new(); @@ -320,17 +318,25 @@ pub async fn load(name: &String) -> Option> { }; let community = Community { - name: json_content["name"].as_str()?.to_string(), + name: json_content["name"].as_str().unwrap().to_string(), owner_id: Arc::new(RwLock::new(json_content["owner_id"].as_i64().unwrap_or(0))), members: users, roles, permissions, private_key: Secret::from_bytes( - &STANDARD.decode(json_content["private_key"].as_str()?).ok()?, - )?, - public_key: PublicKey::from(&Secret::from_bytes( - &STANDARD.decode(json_content["private_key"].as_str()?).ok()?, - )?), + &STANDARD + .decode(json_content["private_key"].as_str().unwrap()) + .unwrap(), + ) + .unwrap(), + public_key: PublicKey::from( + &Secret::from_bytes( + &STANDARD + .decode(json_content["private_key"].as_str().unwrap()) + .unwrap(), + ) + .unwrap(), + ), interactables: Arc::new(RwLock::new(Vec::new())), connections: Arc::new(RwLock::new(HashMap::new())), }; @@ -340,7 +346,7 @@ pub async fn load(name: &String) -> Option> { file_util::get_children(&format!("communities/{}/interactables/", name)); for file in interactable_files { if file.contains(".json") { - let Some(name) = file.split('.').next().map(str::to_string) else { continue }; + let name = file.split('.').next().unwrap().to_string(); let interactable: Box = registry::load(comarc.clone(), String::new(), name).await; comarc.add_interactable(Arc::new(interactable)).await; diff --git a/communities/src/community_connection.rs b/src/communities/community_connection.rs similarity index 74% rename from communities/src/community_connection.rs rename to src/communities/community_connection.rs index ff0aff2..62df251 100644 --- a/communities/src/community_connection.rs +++ b/src/communities/community_connection.rs @@ -21,20 +21,6 @@ use tungstenite::Message; use tungstenite::Utf8Bytes; use uuid::Uuid; use x448::PublicKey; - -trait CommunicationResponseExt { - fn with_request_id(self, request: &CommunicationValue) -> Self; -} - -impl CommunicationResponseExt for CommunicationValue { - fn with_request_id(mut self, request: &CommunicationValue) -> Self { - self = self.without_id(); - if let Some(id) = request.id() { - self = self.with_id(id); - } - self - } -} pub struct CommunityConnection { pub sender: Arc>, Message>>>, pub receiver: Arc>>>>, @@ -44,6 +30,7 @@ pub struct CommunityConnection { challenged: Arc>, challenge: Arc>, auth: Arc>>, + pub ping: Arc>, } impl CommunityConnection { pub fn new( @@ -60,14 +47,13 @@ impl CommunityConnection { challenged: Arc::new(RwLock::new(false)), challenge: Arc::new(RwLock::new(String::new())), auth: Arc::new(RwLock::new(None)), + ping: Arc::new(RwLock::new(-1)), }) } pub async fn send_message(&self, message: &CommunicationValue) { let mut sender = self.sender.write().await; // Access the SplitSink let message_text = Message::Text(Utf8Bytes::from(message.to_json().to_string())); - if let Err(error) = sender.send(message_text).await { - log::error!("failed to send community message: {error}"); - } + sender.send(message_text).await.unwrap(); // Send the message via the SplitSink } pub async fn get_community(&self) -> Option> { self.community.read().await.clone() @@ -84,12 +70,12 @@ impl CommunityConnection { let user_id = self.get_user_id().await; cv = cv.with_sender(user_id); - if cv.is_type(CommunicationType::Identification) && !self.is_identified().await { + if cv.is_type(CommunicationType::identification) && !self.is_identified().await { self.handle_identification(cv).await; return; } - if cv.is_type(CommunicationType::ChallengeResponse) && !self.is_identified().await { + if cv.is_type(CommunicationType::challenge_response) && !self.is_identified().await { self.handle_challenge_response(cv).await; return; } @@ -98,26 +84,30 @@ impl CommunityConnection { return; } - if cv.is_type(CommunicationType::ClientChanged) { + if cv.is_type(CommunicationType::ping) { + self.handle_ping(cv).await; + return; + } + + if cv.is_type(CommunicationType::client_changed) { //self.handle_client_changed(cv).await; return; } - if cv.is_type(CommunicationType::Function) { + if cv.is_type(CommunicationType::function) { self.handle_function(cv).await; return; } } async fn handle_function(&self, cv: CommunicationValue) { - let Some(name) = cv.get_data(DataType::Name).as_str() else { return }; - let Some(path) = cv.get_data(DataType::Path).as_str() else { return }; - let Some(function) = cv.get_data(DataType::Function).as_str() else { return }; + let name = cv.get_data(DataTypes::name).unwrap().as_str().unwrap(); + let path = cv.get_data(DataTypes::path).unwrap().as_str().unwrap(); + let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap(); let result = self .get_community() .await - .ok_or(()) - .unwrap_or_else(|_| return) + .unwrap() .run_function(self.get_user_id().await, name, path, function, &cv) .await; @@ -125,13 +115,13 @@ impl CommunityConnection { } async fn handle_identification(&self, cv: CommunicationValue) { let user_id = cv - .get_data(DataType::UserId) + .get_data(DataTypes::user_id) .unwrap_or(&JsonValue::Number(Number::from(0))) .as_i64() .unwrap_or(0); let Some(user) = get_user(user_id) else { - self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) .await; return; }; @@ -161,7 +151,7 @@ impl CommunityConnection { let user_public_key_bytes = match STANDARD.decode(&user.public_key) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) .await; return; } @@ -170,14 +160,14 @@ impl CommunityConnection { let user_pub_key: PublicKey = match PublicKey::from_bytes(&user_public_key_bytes) { Some(key) => key, __ => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) .await; return; } }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; @@ -188,7 +178,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -210,7 +200,7 @@ impl CommunityConnection { let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) { Ok(data) => data, Err(_) => { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -219,21 +209,21 @@ impl CommunityConnection { let mut encrypted_out = nonce_bytes.to_vec(); encrypted_out.extend(encrypted_challenge); - let response = CommunicationValue::new(CommunicationType::Challenge) + let response = CommunicationValue::new(CommunicationType::challenge) .add_data_str( - DataType::PublicKey, + DataTypes::public_key, STANDARD.encode(community_public_key.as_bytes()), ) - .add_data_str(DataType::Challenge, STANDARD.encode(&encrypted_out)) - .with_request_id(&cv); + .add_data_str(DataTypes::challenge, STANDARD.encode(&encrypted_out)) + .with_id(cv.get_id()); self.send_message(&response).await; } async fn handle_challenge_response(self: Arc, cv: CommunicationValue) { - let client_challenge_response_b64 = match cv.get_data(DataType::Challenge) { + let client_challenge_response_b64 = match cv.get_data(DataTypes::challenge) { Some(data) => data.to_string(), _ => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidData) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -242,38 +232,38 @@ impl CommunityConnection { let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidData) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } }; if challenge_response_bytes.len() < 12 { - self.send_error_response(&cv, CommunicationType::ErrorInvalidData) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } let Some(user) = self.auth.read().await.clone() else { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else { - self.send_error_response(&cv, CommunicationType::ErrorInvalidData) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else { - self.send_error_response(&cv, CommunicationType::ErrorInvalidPublicKey) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; @@ -283,7 +273,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -307,7 +297,7 @@ impl CommunityConnection { let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) { Ok(pt) => pt, Err(_) => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -316,7 +306,7 @@ impl CommunityConnection { let client_response = match String::from_utf8(decrypted_bytes) { Ok(str) => str, Err(_) => { - self.send_error_response(&cv, CommunicationType::ErrorInvalidData) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } @@ -325,7 +315,7 @@ impl CommunityConnection { let expected_challenge = self.challenge.read().await.clone(); if client_response != expected_challenge { - self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; self.close().await; return; @@ -337,7 +327,7 @@ impl CommunityConnection { } let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv, CommunicationType::ErrorInternal) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; }; @@ -345,15 +335,15 @@ impl CommunityConnection { let user_id = self.get_user_id().await; if user_id == 0 { - self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) + self.send_error_response(&cv.get_id(), CommunicationType::error) .await; return; } arc.add_connection(self.clone()).await; - let response = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_data(DataType::Interactables, { + let response = CommunicationValue::new(CommunicationType::identification_response) + .add_data(DataTypes::interactables, { let a: Vec>> = arc.get_interactables(user_id).await; let mut c: JsonValue = JsonValue::new_object(); for b in a { @@ -364,17 +354,13 @@ impl CommunityConnection { } c }) - .with_request_id(&cv); + .with_id(cv.get_id()); self.send_message(&response).await; } - async fn send_error_response( - &self, - request: &CommunicationValue, - error_type: CommunicationType, - ) { - let error = CommunicationValue::new(error_type).with_request_id(request); + async fn send_error_response(&self, message_id: &Uuid, error_type: CommunicationType) { + let error = CommunicationValue::new(error_type).with_id(*message_id); self.send_message(&error).await; } pub async fn close(&self) { @@ -384,11 +370,27 @@ impl CommunityConnection { pub async fn handle_close(self: Arc) { if self.is_identified().await { if self.get_user_id().await != 0 { - if let Some(community) = self.community.read().await.as_ref() { - community.remove_connection(self.clone()).await; - } + self.community + .read() + .await + .as_ref() + .unwrap() + .remove_connection(self.clone()) + .await; } } } + async fn handle_ping(&self, cv: CommunicationValue) { + if let Some(last_ping) = cv.get_data(DataTypes::last_ping) { + if let Ok(ping_val) = last_ping.to_string().parse::() { + let mut ping_guard = self.ping.write().await; + *ping_guard = ping_val; + } + } + + let response = CommunicationValue::new(CommunicationType::pong).with_id(cv.get_id()); + + self.send_message(&response).await; + } } diff --git a/communities/src/community_manager.rs b/src/communities/community_manager.rs similarity index 100% rename from communities/src/community_manager.rs rename to src/communities/community_manager.rs diff --git a/communities/src/interactables/category.rs b/src/communities/interactables/category.rs similarity index 89% rename from communities/src/interactables/category.rs rename to src/communities/interactables/category.rs index 2896e8c..6b6415f 100644 --- a/communities/src/interactables/category.rs +++ b/src/communities/interactables/category.rs @@ -1,120 +1,121 @@ -use crate::communities::{community::Community, interactables::interactable::Interactable}; -use async_trait::async_trait; -use json::JsonValue; -use std::any::Any; -use std::sync::Arc; -use mtp::codec::CommunicationValue; -use uuid::Uuid; - -pub struct Category { - id: Uuid, - name: String, - path: String, - community: Arc, - children: Vec>>, -} -impl Category { - pub fn new() -> Category { - Category { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - children: Vec::new(), - } - } - pub fn get_child(&self, path: String, name: String) -> Option>> { - if path.is_empty() { - self.children - .iter() - .find(|child| child.get_name() == &name) - .cloned() - } else { - let sub_module = path.split('/').next()?; - let next = self - .children - .iter() - .find(|child| child.get_name() == sub_module)?; - if next.get_codec() == "category" { - let next_cat = next.as_any().downcast_ref::()?; - next_cat.get_child(path, name) - } else { - Some(next.clone()) - } - } - } - pub fn get_children(&self) -> Vec>> { - self.children.iter().map(|child| child.clone()).collect() - } -} - -#[async_trait] -impl Interactable for Category { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "category".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - let mut v = JsonValue::new_object(); - for child in &self.children { - let mut subject = JsonValue::new_object(); - subject["codec"] = JsonValue::String(child.get_codec()); - subject["data"] = child.get_data(); - v[child.get_name()] = subject; - } - v - } - async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue { - CommunicationValue::new(CommunicationType::ErrorInternal) - } - fn to_json(&self) -> JsonValue { - let mut v = JsonValue::new_object(); - v["children"] = JsonValue::new_array(); - for child in &self.children { - let _ = v["children"].push(child.to_json()); - } - v - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::communities::{community::Community, interactables::interactable::Interactable}; +use async_trait::async_trait; +use json::JsonValue; +use std::any::Any; +use std::sync::Arc; +use ttp_core::CommunicationValue; +use uuid::Uuid; + +pub struct Category { + id: Uuid, + name: String, + path: String, + community: Arc, + children: Vec>>, +} +impl Category { + pub fn new() -> Category { + Category { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + children: Vec::new(), + } + } + pub fn get_child(&self, path: String, name: String) -> Option>> { + if path.is_empty() { + self.children + .iter() + .find(|child| child.get_name() == &name) + .cloned() + } else { + let sub_module = path.split("/").next().unwrap(); + let next = self + .children + .iter() + .find(|child| child.get_name() == sub_module) + .unwrap(); + if next.get_codec() == "category" { + let next_cat = next.as_any().downcast_ref::().unwrap(); + next_cat.get_child(path, name) + } else { + Some(next.clone()) + } + } + } + pub fn get_children(&self) -> Vec>> { + self.children.iter().map(|child| child.clone()).collect() + } +} + +#[async_trait] +impl Interactable for Category { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "category".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + let mut v = JsonValue::new_object(); + for child in &self.children { + let mut subject = JsonValue::new_object(); + subject["codec"] = JsonValue::String(child.get_codec()); + subject["data"] = child.get_data(); + v[child.get_name()] = subject; + } + v + } + async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue { + CommunicationValue::new(CommunicationType::error) + } + fn to_json(&self) -> JsonValue { + let mut v = JsonValue::new_object(); + v["children"] = JsonValue::new_array(); + for child in &self.children { + let _ = v["children"].push(child.to_json()); + } + v + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/communities/src/interactables/interactable.rs b/src/communities/interactables/interactable.rs similarity index 93% rename from communities/src/interactables/interactable.rs rename to src/communities/interactables/interactable.rs index dd7f326..0521de6 100644 --- a/communities/src/interactables/interactable.rs +++ b/src/communities/interactables/interactable.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use json::JsonValue; use std::any::Any; use std::sync::Arc; -use mtp::codec::CommunicationValue; +use ttp_core::CommunicationValue; use uuid::Uuid; pub type InteractableFactory = fn() -> Box; diff --git a/communities/src/interactables/registry.rs b/src/communities/interactables/registry.rs similarity index 100% rename from communities/src/interactables/registry.rs rename to src/communities/interactables/registry.rs diff --git a/communities/src/interactables/text_chat.rs b/src/communities/interactables/text_chat.rs similarity index 73% rename from communities/src/interactables/text_chat.rs rename to src/communities/interactables/text_chat.rs index 780d8ed..88e1545 100644 --- a/communities/src/interactables/text_chat.rs +++ b/src/communities/interactables/text_chat.rs @@ -1,278 +1,261 @@ -use crate::{ - communities::{ - community::Community, community_connection::CommunityConnection, - interactables::interactable::Interactable, - }, - log, - util::file_util::{get_children, load_file, save_file}, -}; -use async_trait::async_trait; -use json::{JsonValue, array, object}; -use iota_util::mtp_compat::{OptionalDataValueExt, RequiredCommunicationFields}; -use std::fs; -use std::path::Path; -use std::sync::Arc; -use std::{any::Any, collections::HashMap}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType}; -use uuid::Uuid; -pub struct TextChat { - id: Uuid, - name: String, - path: String, - community: Arc, -} -impl TextChat { - pub fn new() -> TextChat { - TextChat { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - } - } - pub fn add_message(&self, send_time: u128, sender: i64, message: &str) { - let Ok(send_time) = i64::try_from(send_time) else { - log!("Message timestamp exceeds local storage range"); - return; - }; - let user_dir = &format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - ); - - let working_dir = iota_util::file_util::get_directory(); - let full_dir = Path::new(&working_dir).join(user_dir); - if let Err(e) = fs::create_dir_all(&full_dir) { - log!("Failed to create chat directory: {}", e); - return; - } - - let mut chunk_index = 0; - let mut message_chunk = array![]; - - // find latest chunk not full (max 800 msgs) - loop { - let file_name = format!("msgs_{}.json", chunk_index); - let file_content = load_file(&user_dir, &file_name); - - if !file_content.is_empty() { - if let Ok(current_chunk) = json::parse(&file_content) { - if current_chunk.is_array() && current_chunk.len() < 800 { - message_chunk = current_chunk; - break; - } - } else { - log!("Failed to parse existing JSON file: {}", file_name); - } - } else { - break; - } - - chunk_index += 1; - if chunk_index > 1000 { - log!("Too many message chunks. Aborting add."); - return; - } - } - - let json_obj = object! { - "timestamp" => send_time, - "content" => message, - "sender" => sender.to_string(), - }; - - if let Err(e) = message_chunk.push(json_obj) { - log!("Failed to push new message into JSON array: {}", e); - return; - } - - let file_name = format!("msgs_{}.json", chunk_index); - log!("Saving message to {}/{}", user_dir, file_name); - save_file(&user_dir, &file_name, &message_chunk.dump()); - } - pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue { - let mut messages = array![]; - - let mut latest_chunk_index: i32 = -1; - let files = get_children(&format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - )); - - for entry in files { - if let Some(num) = { - entry - .strip_prefix("msgs_") - .and_then(|s| s.strip_suffix(".json")) - } { - if let Ok(index) = num.parse::() { - if index > latest_chunk_index { - latest_chunk_index = index; - } - } - } - } - - if latest_chunk_index == -1 { - return messages; - } - - let mut to_skip = loaded_messages; - let mut needed = amount; - - for chunk_index in (0..=latest_chunk_index).rev() { - if needed == 0 { - break; - } - let file_name = format!("msgs_{}.json", chunk_index); - let file_content = load_file( - &format!( - "communities/{}/interactables/{}/{}", - self.get_community().get_name(), - self.get_path(), - self.get_name() - ), - &file_name, - ); - if file_content.is_empty() { - continue; - } - if let Ok(chunk) = json::parse(&file_content) { - for i in (0..chunk.len()).rev() { - if needed == 0 { - break; - } - if to_skip > 0 { - to_skip -= 1; - continue; - } - messages.push(chunk[i].clone()).unwrap(); - needed -= 1; - } - } - } - - messages - } -} -#[async_trait] -impl Interactable for TextChat { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "text".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - JsonValue::new_object() - } - async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { - let payload = cv.get_data(DataType::Payload).as_container().unwrap(); - if cv.get_data(DataType::Function).as_str().unwrap() == "get_messages" { - let amount = payload.get(DataType::Amount).as_i64().unwrap(); - let loaded_messages = payload["loaded_messages"].as_i64().unwrap(); - let messages = self.get_messages(loaded_messages, amount).clone(); - let mut payload = JsonValue::new_object(); - payload["messages"] = messages; - return CommunicationValue::new(CommunicationType::Function) - .with_request_id(&cv) - .add_data_str(DataType::Name, self.name.clone()) - .add_data_str(DataType::Path, self.path.clone()) - .add_data_str(DataType::Result, "message_chunk".to_string()) - .add_data(DataType::Payload, payload); - } - if cv.get_data(DataType::Function).unwrap().as_str().unwrap() == "send_message" { - let message = payload["message"].as_str().unwrap(); - let sender = match cv.require_sender() { - Ok(sender) => sender, - Err(_) => return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(&cv), - }; - let milliseconds_timestamp: u128 = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - let Ok(sender) = i64::try_from(sender) else { - return CommunicationValue::new(CommunicationType::ErrorInvalidData) - .with_request_id(&cv); - }; - self.add_message(milliseconds_timestamp, sender, message); - - let mut distribution_payload = JsonValue::new_object(); - distribution_payload["message"] = JsonValue::String(message.to_string()); - distribution_payload["sender_id"] = JsonValue::String(sender.to_string()); - distribution_payload["send_time"] = - JsonValue::String(milliseconds_timestamp.to_string()); - let distribution = CommunicationValue::new(CommunicationType::Update) - .with_request_id(&cv) - .add_data_str(DataType::Name, self.name.clone()) - .add_data_str(DataType::Path, self.path.clone()) - .add_data_str(DataType::Result, "message_live".to_string()) - .add_data(DataType::Payload, distribution_payload); - - let connections: HashMap>> = - self.get_community().get_connections().await.clone(); - - for con in connections.values() { - for c in con { - let cd: &Arc = c; - cd.send_message(&distribution).await; - } - } - return CommunicationValue::new(CommunicationType::Function) - .with_request_id(&cv) - .add_data_str(DataType::Name, self.name.clone()) - .add_data_str(DataType::Path, self.path.clone()) - .add_data_str(DataType::Result, "message_received".to_string()) - .add_data(DataType::Payload, JsonValue::new_object()); - } - CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv) - } - fn to_json(&self) -> JsonValue { - JsonValue::new_object() - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::{ + communities::{ + community::Community, community_connection::CommunityConnection, + interactables::interactable::Interactable, + }, + log, + util::file_util::{get_children, load_file, save_file}, +}; +use async_trait::async_trait; +use json::{JsonValue, array, object}; +use std::fs; +use std::sync::Arc; +use std::{any::Any, collections::HashMap}; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes}; +use uuid::Uuid; +pub struct TextChat { + id: Uuid, + name: String, + path: String, + community: Arc, +} +impl TextChat { + pub fn new() -> TextChat { + TextChat { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + } + } + pub fn add_message(&self, send_time: u128, sender: i64, message: &str) { + let user_dir = &format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + ); + + if let Err(e) = fs::create_dir_all(user_dir) { + log!("Failed to create chat directory: {}", e); + return; + } + + let mut chunk_index = 0; + let mut message_chunk = array![]; + + // find latest chunk not full (max 800 msgs) + loop { + let file_name = format!("msgs_{}.json", chunk_index); + let file_content = load_file(&user_dir, &file_name); + + if !file_content.is_empty() { + if let Ok(current_chunk) = json::parse(&file_content) { + if current_chunk.is_array() && current_chunk.len() < 800 { + message_chunk = current_chunk; + break; + } + } else { + log!("Failed to parse existing JSON file: {}", file_name); + } + } else { + break; + } + + chunk_index += 1; + if chunk_index > 1000 { + log!("Too many message chunks. Aborting add."); + return; + } + } + + let json_obj = object! { + "timestamp" => send_time as i64, + "content" => message, + "sender" => sender.to_string(), + }; + + if let Err(e) = message_chunk.push(json_obj) { + log!("Failed to push new message into JSON array: {}", e); + return; + } + + let file_name = format!("msgs_{}.json", chunk_index); + log!("Saving message to {}/{}", user_dir, file_name); + save_file(&user_dir, &file_name, &message_chunk.dump()); + } + pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue { + let mut messages = array![]; + + let mut latest_chunk_index: i32 = -1; + let files = get_children(&format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + )); + + for entry in files { + if let Some(num) = { + entry + .strip_prefix("msgs_") + .and_then(|s| s.strip_suffix(".json")) + } { + if let Ok(index) = num.parse::() { + if index > latest_chunk_index { + latest_chunk_index = index; + } + } + } + } + + if latest_chunk_index == -1 { + return messages; + } + + let mut to_skip = loaded_messages; + let mut needed = amount; + + for chunk_index in (0..=latest_chunk_index).rev() { + if needed == 0 { + break; + } + let file_name = format!("msgs_{}.json", chunk_index); + let file_content = load_file( + &format!( + "communities/{}/interactables/{}/{}", + self.get_community().get_name(), + self.get_path(), + self.get_name() + ), + &file_name, + ); + if file_content.is_empty() { + continue; + } + if let Ok(chunk) = json::parse(&file_content) { + for i in (0..chunk.len()).rev() { + if needed == 0 { + break; + } + if to_skip > 0 { + to_skip -= 1; + continue; + } + messages.push(chunk[i].clone()).unwrap(); + needed -= 1; + } + } + } + + messages + } +} +#[async_trait] +impl Interactable for TextChat { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "text".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + JsonValue::new_object() + } + async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { + let payload = cv.get_data(DataTypes::payload).as_container().unwrap(); + if cv.get_data(DataTypes::function).as_str().unwrap() == "get_messages" { + let amount = payload.get(DataTypes::amount).as_i64().unwrap(); + let loaded_messages = payload["loaded_messages"].as_i64().unwrap(); + let messages = self.get_messages(loaded_messages, amount).clone(); + let mut payload = JsonValue::new_object(); + payload["messages"] = messages; + return CommunicationValue::new(CommunicationType::function) + .with_id(cv.get_id()) + .add_data_str(DataTypes::name, self.name.clone()) + .add_data_str(DataTypes::path, self.path.clone()) + .add_data_str(DataTypes::result, "message_chunk".to_string()) + .add_data(DataTypes::payload, payload); + } + if cv.get_data(DataTypes::function).unwrap().as_str().unwrap() == "send_message" { + let message = payload["message"].as_str().unwrap(); + let milliseconds_timestamp: u128 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + self.add_message(milliseconds_timestamp, cv.get_sender(), message); + + let mut distribution_payload = JsonValue::new_object(); + distribution_payload["message"] = JsonValue::String(message.to_string()); + distribution_payload["sender_id"] = JsonValue::String(cv.get_sender().to_string()); + distribution_payload["send_time"] = + JsonValue::String(milliseconds_timestamp.to_string()); + let distribution = CommunicationValue::new(CommunicationType::update) + .with_id(cv.get_id()) + .add_data_str(DataTypes::name, self.name.clone()) + .add_data_str(DataTypes::path, self.path.clone()) + .add_data_str(DataTypes::result, "message_live".to_string()) + .add_data(DataTypes::payload, distribution_payload); + + let connections: HashMap>> = + self.get_community().get_connections().await.clone(); + + for con in connections.values() { + for c in con { + let cd: &Arc = c; + cd.send_message(&distribution).await; + } + } + return CommunicationValue::new(CommunicationType::function) + .with_id(cv.get_id()) + .add_data_str(DataTypes::name, self.name.clone()) + .add_data_str(DataTypes::path, self.path.clone()) + .add_data_str(DataTypes::result, "message_received".to_string()) + .add_data(DataTypes::payload, JsonValue::new_object()); + } + CommunicationValue::new(CommunicationType::error).with_id(cv.get_id()) + } + fn to_json(&self) -> JsonValue { + JsonValue::new_object() + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/communities/src/interactables/voice_chat.rs b/src/communities/interactables/voice_chat.rs similarity index 80% rename from communities/src/interactables/voice_chat.rs rename to src/communities/interactables/voice_chat.rs index 7681765..2a2e3a2 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/src/communities/interactables/voice_chat.rs @@ -1,188 +1,187 @@ -use crate::communities::{community::Community, interactables::interactable::Interactable}; -use async_trait::async_trait; -use json::JsonValue; -use iota_util::mtp_compat::OptionalDataValueExt; -use std::sync::Arc; -use std::{any::Any, sync::RwLock}; -use uuid::Uuid; -pub enum CallUserState { - Active, - Muted, - Deafed, -} -impl CallUserState { - pub fn parse(state: &str) -> CallUserState { - match state { - "active" => CallUserState::Active, - "muted" => CallUserState::Muted, - "deafed" => CallUserState::Deafed, - _ => CallUserState::Active, - } - } - pub fn to_string(&self) -> String { - match self { - CallUserState::Active => "active".to_string(), - CallUserState::Muted => "muted".to_string(), - CallUserState::Deafed => "deafed".to_string(), - } - } -} - -pub struct CallUser { - pub user_id: Uuid, - pub user_state: CallUserState, - pub streaming: bool, -} - -pub struct VoiceChat { - id: Uuid, - name: String, - path: String, - community: Arc, - users: RwLock>, -} -impl VoiceChat { - pub fn new() -> VoiceChat { - VoiceChat { - id: Uuid::new_v4(), - name: String::new(), - path: String::new(), - community: Arc::new(Community::new()), - users: RwLock::new(Vec::new()), - } - } - pub fn update_user_state( - self: Arc, - user_id: Uuid, - state: CallUserState, - streaming: bool, - ) { - if let Some(user) = self - .users - .write() - .unwrap() - .iter_mut() - .find(|u| u.user_id == user_id) - { - user.user_state = state; - user.streaming = streaming; - } - } -} -#[async_trait] -impl Interactable for VoiceChat { - fn get_id(&self) -> &Uuid { - &self.id - } - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn get_codec(&self) -> String { - "voice".to_string() - } - fn set_name(&mut self, name: String) { - self.name = name; - } - fn set_path(&mut self, path: String) { - self.path = path; - } - fn get_community(&self) -> &Arc { - &self.community - } - fn set_community(&mut self, community: Arc) { - self.community = community; - } - fn get_name(&self) -> &String { - &self.name - } - fn get_path(&self) -> &String { - &self.path - } - fn get_total_path(&self) -> String { - String::new() + &self.path + "/" + &self.name - } - fn get_data(&self) -> JsonValue { - let mut data = JsonValue::new_object(); - let mut active_users = JsonValue::new_object(); - for user in self.users.read().unwrap().iter() { - let mut user_data = JsonValue::new_object(); - let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string())); - let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming)); - let _ = active_users.insert(&user.user_id.to_string(), user_data); - } - let _ = data.insert("active_users", active_users); - data - } - async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { - let payload = cv.get_data(DataType::Payload).unwrap(); - let function = cv.get_data(DataType::Function).unwrap().as_str().unwrap(); - - if function == "get_call" { - let sender_id = payload["sender_id"].as_str().unwrap(); - let message_id = payload["message"].as_str().unwrap(); - let send_time = payload["send_time"].as_str().unwrap(); - - let mut response_payload = JsonValue::new_object(); - response_payload["sender_id"] = JsonValue::String(sender_id.to_string()); - response_payload["message"] = JsonValue::String(message_id.to_string()); - response_payload["send_time"] = JsonValue::String(send_time.to_string()); - - return CommunicationValue::new(CommunicationType::Function) - .with_request_id(&cv) - .add_data_str(DataType::Name, self.name.clone()) - .add_data_str(DataType::Path, self.path.clone()) - .add_data_str(DataType::Result, "getting_call".to_string()) - .add_data(DataType::Payload, response_payload); - } - - if function == "update_user_state" { - let user_id = payload["user_id"].as_str().unwrap(); - let state = payload["state"].as_str().unwrap(); - let streaming = payload["streaming"].as_bool().unwrap(); - - if let Some(user) = self - .users - .write() - .unwrap() - .iter_mut() - .find(|u| u.user_id == Uuid::parse_str(user_id).unwrap()) - { - user.user_state = CallUserState::parse(state); - user.streaming = streaming; - } - let mut response_payload = JsonValue::new_object(); - response_payload["user_id"] = JsonValue::Number(user_id); - response_payload["state"] = JsonValue::String(state.to_string()); - response_payload["streaming"] = JsonValue::Boolean(streaming); - - return CommunicationValue::new(CommunicationType::Update) - .with_request_id(&cv) - .add_data_str(DataType::Name, self.name.clone()) - .add_data_str(DataType::Path, self.path.clone()) - .add_data_str(DataType::Result, "user_changed".to_string()) - .add_data(DataType::Payload, response_payload); - } - CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv) - } - - fn to_json(&self) -> JsonValue { - let v = JsonValue::new_object(); - v - } - fn load( - &mut self, - community: Arc, - id: Uuid, - path: String, - name: String, - _json: &JsonValue, - ) { - self.community = community; - self.id = id; - self.name = name; - self.path = path; - } -} +use crate::communities::{community::Community, interactables::interactable::Interactable}; +use async_trait::async_trait; +use json::JsonValue; +use std::sync::Arc; +use std::{any::Any, sync::RwLock}; +use uuid::Uuid; +pub enum CallUserState { + Active, + Muted, + Deafed, +} +impl CallUserState { + pub fn parse(state: &str) -> CallUserState { + match state { + "active" => CallUserState::Active, + "muted" => CallUserState::Muted, + "deafed" => CallUserState::Deafed, + _ => CallUserState::Active, + } + } + pub fn to_string(&self) -> String { + match self { + CallUserState::Active => "active".to_string(), + CallUserState::Muted => "muted".to_string(), + CallUserState::Deafed => "deafed".to_string(), + } + } +} + +pub struct CallUser { + pub user_id: Uuid, + pub user_state: CallUserState, + pub streaming: bool, +} + +pub struct VoiceChat { + id: Uuid, + name: String, + path: String, + community: Arc, + users: RwLock>, +} +impl VoiceChat { + pub fn new() -> VoiceChat { + VoiceChat { + id: Uuid::new_v4(), + name: String::new(), + path: String::new(), + community: Arc::new(Community::new()), + users: RwLock::new(Vec::new()), + } + } + pub fn update_user_state( + self: Arc, + user_id: Uuid, + state: CallUserState, + streaming: bool, + ) { + if let Some(user) = self + .users + .write() + .unwrap() + .iter_mut() + .find(|u| u.user_id == user_id) + { + user.user_state = state; + user.streaming = streaming; + } + } +} +#[async_trait] +impl Interactable for VoiceChat { + fn get_id(&self) -> &Uuid { + &self.id + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn get_codec(&self) -> String { + "voice".to_string() + } + fn set_name(&mut self, name: String) { + self.name = name; + } + fn set_path(&mut self, path: String) { + self.path = path; + } + fn get_community(&self) -> &Arc { + &self.community + } + fn set_community(&mut self, community: Arc) { + self.community = community; + } + fn get_name(&self) -> &String { + &self.name + } + fn get_path(&self) -> &String { + &self.path + } + fn get_total_path(&self) -> String { + String::new() + &self.path + "/" + &self.name + } + fn get_data(&self) -> JsonValue { + let mut data = JsonValue::new_object(); + let mut active_users = JsonValue::new_object(); + for user in self.users.read().unwrap().iter() { + let mut user_data = JsonValue::new_object(); + let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string())); + let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming)); + let _ = active_users.insert(&user.user_id.to_string(), user_data); + } + let _ = data.insert("active_users", active_users); + data + } + async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue { + let payload = cv.get_data(DataTypes::payload).unwrap(); + let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap(); + + if function == "get_call" { + let sender_id = payload["sender_id"].as_str().unwrap(); + let message_id = payload["message"].as_str().unwrap(); + let send_time = payload["send_time"].as_str().unwrap(); + + let mut response_payload = JsonValue::new_object(); + response_payload["sender_id"] = JsonValue::String(sender_id.to_string()); + response_payload["message"] = JsonValue::String(message_id.to_string()); + response_payload["send_time"] = JsonValue::String(send_time.to_string()); + + return CommunicationValue::new(CommunicationType::function) + .with_id(cv.get_id()) + .add_data_str(DataTypes::name, self.name.clone()) + .add_data_str(DataTypes::path, self.path.clone()) + .add_data_str(DataTypes::result, "getting_call".to_string()) + .add_data(DataTypes::payload, response_payload); + } + + if function == "update_user_state" { + let user_id = payload["user_id"].as_str().unwrap(); + let state = payload["state"].as_str().unwrap(); + let streaming = payload["streaming"].as_bool().unwrap(); + + if let Some(user) = self + .users + .write() + .unwrap() + .iter_mut() + .find(|u| u.user_id == Uuid::parse_str(user_id).unwrap()) + { + user.user_state = CallUserState::parse(state); + user.streaming = streaming; + } + let mut response_payload = JsonValue::new_object(); + response_payload["user_id"] = JsonValue::String(user_id.to_string()); + response_payload["state"] = JsonValue::String(state.to_string()); + response_payload["streaming"] = JsonValue::Boolean(streaming); + + return CommunicationValue::new(CommunicationType::update) + .with_id(cv.get_id()) + .add_data_str(DataTypes::name, self.name.clone()) + .add_data_str(DataTypes::path, self.path.clone()) + .add_data_str(DataTypes::result, "user_changed".to_string()) + .add_data(DataTypes::payload, response_payload); + } + CommunicationValue::new(CommunicationType::error).with_id(cv.get_id()) + } + + fn to_json(&self) -> JsonValue { + let v = JsonValue::new_object(); + v + } + fn load( + &mut self, + community: Arc, + id: Uuid, + path: String, + name: String, + _json: &JsonValue, + ) { + self.community = community; + self.id = id; + self.name = name; + self.path = path; + } +} diff --git a/communities/src/lib.rs b/src/communities/mod.rs similarity index 95% rename from communities/src/lib.rs rename to src/communities/mod.rs index 237b709..ecad116 100644 --- a/communities/src/lib.rs +++ b/src/communities/mod.rs @@ -1,13 +1,13 @@ -pub mod community_manager; -pub mod interactables { - pub mod category; - pub mod interactable; - pub mod registry; - pub mod text_chat; - pub mod voice_chat; -} -pub mod community; -pub mod community_connection; -pub mod perms { - pub mod permission; -} +pub mod community_manager; +pub mod interactables { + pub mod category; + pub mod interactable; + pub mod registry; + pub mod text_chat; + pub mod voice_chat; +} +pub mod community; +pub mod community_connection; +pub mod perms { + pub mod permission; +} diff --git a/communities/src/perms/permission.rs b/src/communities/perms/permission.rs similarity index 100% rename from communities/src/perms/permission.rs rename to src/communities/perms/permission.rs diff --git a/src/gui/app_state.rs b/src/gui/app_state.rs new file mode 100755 index 0000000..a161226 --- /dev/null +++ b/src/gui/app_state.rs @@ -0,0 +1,201 @@ +use crate::{ACTIVE_TASKS, APP_STATE, SHUTDOWN, gui::elements::log_card::UiLogEntry}; +use json::{JsonValue, object}; +use std::{collections::VecDeque, thread, time::Duration}; +use sysinfo::{RefreshKind, System}; + +#[derive(Clone)] +pub struct AppState { + pub logs: VecDeque, + pub cpu: Vec<(f64, f64)>, + pub ram: Vec<(f64, f64)>, + pub ping: Vec<(f64, f64)>, + pub net_up: Vec<(f64, f64)>, + pub net_down: Vec<(f64, f64)>, + pub sys_info: String, +} +const MAX_POINTS: usize = 1000; +const MAX_LOGS: usize = 100; + +impl AppState { + pub fn new() -> Self { + Self { + logs: VecDeque::new(), + cpu: Vec::new(), + ram: Vec::new(), + ping: Vec::new(), + net_up: Vec::new(), + net_down: Vec::new(), + sys_info: String::from("Loading..."), + } + } + + pub fn push_log(&mut self, msg: UiLogEntry) { + if self.logs.len() >= MAX_LOGS { + self.logs.pop_front(); + } + self.logs.push_back(msg); + } + + pub fn get_logs(&self) -> &VecDeque { + &self.logs + } + + pub fn push_cpu(&mut self, pt: (f64, f64)) { + self.cpu.push(pt); + if self.cpu.len() > MAX_POINTS { + self.cpu.remove(0); + } + } + + pub fn push_ram(&mut self, pt: (f64, f64)) { + self.ram.push(pt); + if self.ram.len() > MAX_POINTS { + self.ram.remove(0); + } + } + + pub fn push_ping_val(&mut self, pt: f64) { + self.ping.push((self.ping.len() as f64, pt)); + if self.ping.len() > MAX_POINTS { + self.ping.remove(0); + } + } + + pub fn push_net_up(&mut self, pt: (f64, f64)) { + self.net_up.push(pt); + if self.net_up.len() > MAX_POINTS { + self.net_up.remove(0); + } + } + + pub fn push_net_down(&mut self, pt: (f64, f64)) { + self.net_down.push(pt); + if self.net_down.len() > MAX_POINTS { + self.net_down.remove(0); + } + } + pub fn to_json(&self) -> JsonValue { + let json = object! { + "cpu" => self.cpu + .iter() + .map(|(_, y)| *y) + .collect::>(), + "ram" => self.ram + .iter() + .map(|(_, y)| *y) + .collect::>(), + "ping" => self + .ping + .iter() + .map(|(_, y)| *y) + .collect::>(), + "net_up" => self + .net_up + .iter() + .map(|(_, y)| *y) + .collect::>(), + "net_down" => self + .net_down + .iter() + .map(|(_, y)| *y) + .collect::>(), + }; + json + } + pub fn with_width(&self, width: u16) -> Self { + let mut new = self.clone(); + new.cpu = Self::downsample_to_fit_width(&new.cpu, width); + new.ram = Self::downsample_to_fit_width(&new.ram, width); + new.ping = Self::downsample_to_fit_width(&new.ping, width); + new.net_up = Self::downsample_to_fit_width(&new.net_up, width); + new.net_down = Self::downsample_to_fit_width(&new.net_down, width); + new + } + + fn downsample_to_fit_width(data: &[(f64, f64)], width: u16) -> Vec<(f64, f64)> { + let width_usize = (width as usize) * 2; + let len = data.len(); + + if len >= width_usize { + data[len - width_usize..].to_vec() + } else { + let mut result = Vec::with_capacity(width_usize); + + let dx = 1.0; + let pad_len = width_usize - len; + + let start_x = data + .first() + .map(|(x, _)| x - (dx * pad_len as f64)) + .unwrap_or(0.0); + let _ = data.first().map(|(_, y)| *y).unwrap_or(0.0); + + for i in 0..pad_len { + result.push((start_x + i as f64 * dx, -1 as f64)); + } + + result.extend_from_slice(data); + result + } + } +} + +pub fn setup() { + ACTIVE_TASKS.insert("System info loader".to_string()); + tokio::spawn(async move { + let mut sys = System::new_with_specifics(RefreshKind::everything()); + let mut last_total_received = 0u64; + let mut last_total_transmitted = 0u64; + let mut counter = 0.0; + loop { + if *SHUTDOWN.read().await { + break; + } + sys.refresh_all(); + + let mut tcpu = 0; + for cpu in sys.cpus() { + tcpu += cpu.cpu_usage() as i64; + tcpu /= 2; + } + let ram = (sys.used_memory() as f64 / sys.total_memory() as f64) * 100.0; + + let total_received = 0u64; + let total_transmitted = 0u64; + + let delta_received = if last_total_received == 0 { + 0 + } else { + total_received.saturating_sub(last_total_received) + }; + let delta_transmitted = if last_total_transmitted == 0 { + 0 + } else { + total_transmitted.saturating_sub(last_total_transmitted) + }; + last_total_received = total_received; + last_total_transmitted = total_transmitted; + + let net_down = delta_received as f64; + let net_up = delta_transmitted as f64; + + { + let mut st = APP_STATE.lock().unwrap(); + st.push_cpu((counter, tcpu as f64)); + st.push_ram((counter, ram)); + st.push_net_down((counter, net_down)); + st.push_net_up((counter, net_up)); + + st.sys_info = format!("NetDown: {} NetUp: {}", delta_received, delta_transmitted); + } + + counter += 1.0; + if counter > 30.0 { + thread::sleep(Duration::from_millis(500)); + } else { + thread::sleep(Duration::from_millis(5)); + } + } + ACTIVE_TASKS.remove("System info loader"); + }); +} diff --git a/src/gui/elements/console_card.rs b/src/gui/elements/console_card.rs new file mode 100644 index 0000000..b189bd4 --- /dev/null +++ b/src/gui/elements/console_card.rs @@ -0,0 +1,491 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use ttp_core::{CommunicationType, CommunicationValue}; +use uuid::Uuid; + +use crate::{ + ACTIVE_TASKS, RELOAD, SHUTDOWN, + gui::{ + elements::elements::{Element, InteractableElement, JoinableElement}, + interaction_result::InteractionResult, + ui::FPS, + util::borders::draw_block_joins, + }, + log, log_command, log_cv, + omikron::omikron_connection::OMIKRON_CONNECTION, + users::{user_manager, user_profile::UserProfile}, + util::file_util, +}; +use std::{ + any::Any, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::time::Instant; + +pub struct ConsoleCard { + focused: bool, + pub title: String, + pub content: String, + pub cursor_position: usize, + + borders: Borders, + joins: Borders, + + cursor: Arc>, + last_swap: Arc>, + tab_index: usize, +} + +impl ConsoleCard { + pub fn new(title: &str, content: &str) -> Self { + ConsoleCard { + focused: false, + title: title.to_string(), + content: content.to_string(), + cursor_position: content.chars().count(), + borders: Borders::ALL, + joins: Borders::NONE, + cursor: Arc::new(Mutex::new(true)), + last_swap: Arc::new(Mutex::new(Instant::now())), + tab_index: 0, + } + } + + fn byte_index(&self) -> usize { + self.content + .char_indices() + .nth(self.cursor_position) + .map(|(i, _)| i) + .unwrap_or(self.content.len()) + } + + fn cursor_visible(&self) -> bool { + if !self.focused { + return false; + } + + let mut visible = self.cursor.lock().unwrap(); + let mut last = self.last_swap.lock().unwrap(); + let now = Instant::now(); + + if now.duration_since(*last) >= Duration::from_millis(500) { + *visible = !*visible; + *last = now; + } + + *visible + } + + fn current_prefix(&self) -> Option<&str> { + if self.content.starts_with('/') { + Some("/") + } else { + None + } + } + + fn cursor_spans(&self) -> Vec> { + let cursor_visible = self.cursor_visible(); + let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray); + let mut spans = Vec::new(); + + if self.content.is_empty() { + if self.focused { + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } else { + spans.push(Span::styled(" ", Style::default().fg(Color::White))); + } + spans.push(Span::styled( + "send command ( for info)", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + " send command ( for info)", + Style::default().fg(Color::DarkGray), + )); + } + return spans; + } + + let byte_index = self.byte_index(); + let before = self.content[..byte_index].to_string(); + let after = self.content[byte_index..].to_string(); + + let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0); + + if prefix_len > 0 && before.len() >= prefix_len { + let prefix = &before[..prefix_len]; + let rest = &before[prefix_len..]; + spans.push(Span::styled( + prefix.to_string(), + Self::style_for_part(true, false, false), + )); + if !rest.is_empty() { + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::White), + )); + } + } else if !before.is_empty() { + spans.push(Span::styled( + before.clone(), + Style::default().fg(Color::White), + )); + } + + if cursor_visible { + spans.push(Span::styled(" ", cursor_style)); + } + + if !after.is_empty() { + spans.push(Span::styled(after, Style::default().fg(Color::White))); + } + + spans + } + + fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style { + if is_error { + return Style::default().fg(Color::Red); + } + + if is_hint { + return Style::default().fg(Color::DarkGray); + } + + if is_prefix { + return Style::default().fg(Color::DarkGray); + } + + Style::default().fg(Color::White) + } + + fn render_cursor_spans(&self) -> Vec> { + self.cursor_spans() + } + + fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + fn move_cursor_right(&mut self) { + let len = self.content.chars().count(); + if self.cursor_position < len { + self.cursor_position += 1; + } + } + + fn delete_at_cursor(&mut self) { + if self.content.is_empty() || self.cursor_position == 0 { + return; + } + + let start = self + .content + .char_indices() + .nth(self.cursor_position.saturating_sub(1)) + .map(|(i, _)| i) + .unwrap_or(0); + let end = self.byte_index(); + self.content.replace_range(start..end, ""); + self.cursor_position -= 1; + } + + fn insert_at_cursor(&mut self, c: char) { + let idx = self.byte_index(); + self.content.insert(idx, c); + self.cursor_position += 1; + } +} + +impl Element for ConsoleCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, r: Rect) { + let block = Block::default() + .borders(self.borders) + .title(self.title.clone()) + .title_style(Style::default().fg(Color::White)) + .border_style(if self.focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .style(if self.focused { + Style::default().fg(Color::White) + } else { + Style::default() + }); + + let spans = self.render_cursor_spans(); + let par = Paragraph::new(Line::from(spans)) + .block(block) + .scroll((0, 0)); + f.render_widget(par, r); + draw_block_joins(f, r, self.borders, self.joins); + } +} + +impl JoinableElement for ConsoleCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn as_element(&self) -> &(dyn Element + 'static) { + self + } + + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { + self + } + + fn set_borders(&mut self, borders: Borders) { + self.borders = borders; + } + + fn set_joins(&mut self, joins: Borders) { + self.joins = joins; + } +} + +impl InteractableElement for ConsoleCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn as_element(&self) -> &(dyn Element + 'static) { + self + } + + fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) { + self + } + + fn interact(&mut self, key: KeyEvent) -> InteractionResult { + match key.code { + KeyCode::Enter => { + if self.content.is_empty() { + log!(""); + return InteractionResult::Handled; + } + + let command = self.content.clone(); + let id = Uuid::new_v4(); + let id = id.to_string(); + let id = id.split_at(8).0; + let task_id = format!("command_{}_{}", command, id); + ACTIVE_TASKS.insert(task_id.clone()); + + log_command!("{}", command); + + tokio::spawn(async move { + run_command(&command).await; + ACTIVE_TASKS.remove(&task_id); + }); + + self.content.clear(); + self.cursor_position = 0; + self.tab_index = 0; + InteractionResult::Handled + } + KeyCode::Backspace => { + self.delete_at_cursor(); + InteractionResult::Handled + } + KeyCode::Delete => { + let len = self.content.chars().count(); + if self.cursor_position < len { + let start = self.byte_index(); + let end = self + .content + .char_indices() + .nth(self.cursor_position + 1) + .map(|(i, _)| i) + .unwrap_or(self.content.len()); + self.content.replace_range(start..end, ""); + } + InteractionResult::Handled + } + KeyCode::Left => { + self.move_cursor_left(); + InteractionResult::Handled + } + KeyCode::Right => { + self.move_cursor_right(); + InteractionResult::Handled + } + KeyCode::Home => { + self.cursor_position = 0; + InteractionResult::Handled + } + KeyCode::End => { + self.cursor_position = self.content.chars().count(); + InteractionResult::Handled + } + KeyCode::Tab => { + if let Some(prefix) = self.current_prefix() { + if prefix == "/" { + self.tab_index = self.tab_index.saturating_add(1); + } + } + InteractionResult::Handled + } + _ => { + if let Some(c) = key.code.as_char() { + self.insert_at_cursor(c); + InteractionResult::Handled + } else { + InteractionResult::Unhandled + } + } + } + } + + fn can_focus(&self) -> bool { + true + } + + fn is_focused(&self) -> bool { + self.focused + } + + fn focus(&mut self, f: bool) { + self.focused = f; + } +} + +pub async fn run_command(command: &str) { + let parts = command.split(" ").collect::>(); + + match parts.as_slice() { + ["tasks"] => { + let active_tasks: Vec = + ACTIVE_TASKS.clone().iter().map(|v| v.to_string()).collect(); + let info = if *SHUTDOWN.read().await && *RELOAD.read().await { + "Rebooting, " + } else if *SHUTDOWN.read().await { + "Shutting , " + } else { + "" + }; + log!("{}Active tasks: {:?}", info, active_tasks); + } + ["fps"] => { + let (fps, skips) = *FPS.read().await; + log!("{:.1} FPS with {:.1}% of attempts skipped", fps, skips); + } + + ["help"] => { + log!("Available commands: tasks, fps, ping, user"); + } + + ["help", "tasks"] => { + log!("Tasks command usage: tasks"); + } + ["help", "fps"] => { + log!("FPS command usage: fps"); + } + ["help", "ping"] => { + log!("Ping command usage: ping [time]"); + } + ["help", "user"] => { + log!("User command usage: user add | user remove | user list"); + } + + ["ping"] => { + ping(20).await; + } + ["ping", time] => { + let time = time.parse::().unwrap_or(20); + ping(time).await; + } + ["user", "add", username] => { + if let (Some(user), Some(_)) = user_manager::create_user(username).await { + log!("Created user {}", user.user_id); + } else { + log!("Failed to create user"); + } + } + ["user", "remove", username] => { + if let Some(user) = user_manager::get_user_by_username(username) { + user_manager::remove_user(user.user_id); + log!("Removed user {}", user.user_id); + } else { + log!("Failed to find user"); + } + } + ["user", "list"] => { + let users: Vec = user_manager::get_users(); + for user in users { + let storage = file_util::get_designed_storage(user.user_id); + log!( + "> Username: {}, ID: {}, created at: {}, storage: {}", + user.username, + user.user_id, + user.created_at, + storage + ); + } + } + ["user", "info", username] => { + if let Some(user) = user_manager::get_user_by_username(username) { + user_manager::remove_user(user.user_id); + log!("Removed user {}", user.user_id); + } else { + log!("Failed to find user"); + } + } + ["reload"] | ["restart"] => { + log!("Restarting"); + *RELOAD.write().await = true; + *SHUTDOWN.write().await = true; + } + ["shutdown"] | ["stop"] => { + log!("Shutting down"); + *SHUTDOWN.write().await = true; + } + _ => { + log!("Unknown command"); + } + } +} + +pub async fn ping(time: u64) { + let conn = OMIKRON_CONNECTION.clone(); + + let response_cv = conn + .await_response( + &CommunicationValue::new(CommunicationType::ping), + Some(Duration::from_secs(time)), + ) + .await; + match response_cv { + Ok(response) => log_cv!(response), + Err(err) => log!("Ping error: {:?}", err), + } +} diff --git a/iota-cli/src/elements/elements.rs b/src/gui/elements/elements.rs similarity index 87% rename from iota-cli/src/elements/elements.rs rename to src/gui/elements/elements.rs index 1af14cc..3429e52 100644 --- a/iota-cli/src/elements/elements.rs +++ b/src/gui/elements/elements.rs @@ -3,16 +3,14 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::Borders}; -use crate::{ - interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen, -}; +use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; #[allow(unused)] pub trait Element: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>); + fn render(&self, f: &mut Frame, r: Rect); } #[allow(unused)] diff --git a/src/gui/elements/graph_card.rs b/src/gui/elements/graph_card.rs new file mode 100644 index 0000000..d95f937 --- /dev/null +++ b/src/gui/elements/graph_card.rs @@ -0,0 +1,215 @@ +use std::{any::Any, sync::Arc}; + +use crossterm::event::KeyEvent; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + widgets::{ + Block, Borders, + canvas::{Canvas, Line}, + }, +}; + +use crate::{ + APP_STATE, + gui::{ + elements::elements::{Element, InteractableElement, JoinableElement}, + interaction_result::InteractionResult, + ui::UI, + util::borders::draw_block_joins, + }, +}; + +pub enum GRAPHS { + Ram, + Cpu, + Ping, +} + +impl GRAPHS { + pub fn get_color(&self) -> Color { + match self { + GRAPHS::Ram => Color::Blue, + GRAPHS::Cpu => Color::Red, + GRAPHS::Ping => Color::Green, + } + } + + pub fn get_graph(&self) -> Vec<(f64, f64)> { + match self { + GRAPHS::Ram => APP_STATE.lock().unwrap().with_width(28).ram.clone(), + GRAPHS::Cpu => APP_STATE.lock().unwrap().with_width(28).cpu.clone(), + GRAPHS::Ping => APP_STATE.lock().unwrap().with_width(28).ping.clone(), + } + } + + pub fn get_unit(&self) -> String { + match self { + GRAPHS::Ram => "MB".to_string(), + GRAPHS::Cpu => "%".to_string(), + GRAPHS::Ping => "ms".to_string(), + } + } +} + +#[allow(unused)] +pub struct GraphCard { + ui: Arc, + graph_type: GRAPHS, + + focused: bool, + pub title: String, + + borders: Borders, + joins: Borders, + + open: bool, +} + +impl GraphCard { + pub fn new(ui: Arc, graph_type: GRAPHS, title: String) -> Self { + Self { + ui, + graph_type, + focused: false, + title, + borders: Borders::ALL, + joins: Borders::NONE, + open: true, + } + } + + pub fn set_open(&mut self, open: bool) { + self.open = open; + } +} +impl Element for GraphCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, r: Rect) { + if self.open { + let graph = self.graph_type.get_graph(); + let unit = self.graph_type.get_unit(); + let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); + let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); + let min_y = graph + .iter() + .map(|(_, y)| *y) + .filter(|y| *y > 0.0) + .min_by(|a, b| a.total_cmp(b)) + .unwrap_or(0.0); + let max_y = graph.iter().map(|(_, y)| *y).fold(-1.0, f64::max); + + let block = Block::default() + .title(format!( + "{}:─{}{}─{}min/{}max", + self.title, + graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, + unit, + min_y as i64, + max_y as i64, + )) + .borders(self.borders) + .border_style(if self.focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }); + + let canvas = Canvas::default() + .block(block) + .x_bounds([min_x, max_x]) + .y_bounds([0.0, 100.0]) + .paint(|ctx| { + for (x, y) in &graph { + ctx.draw(&Line { + x1: *x, + y1: 0.0, + x2: *x, + y2: *y, + color: self.graph_type.get_color(), + }); + } + }); + f.render_widget(canvas, r); + } else { + let block = Block::default() + .title("") + .borders(self.borders) + .border_style(if self.focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }); + f.render_widget(block, r); + } + draw_block_joins(f, r, self.borders, self.joins); + } +} + +impl JoinableElement for GraphCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn as_element(&self) -> &dyn Element { + self + } + + fn as_element_mut(&mut self) -> &mut dyn Element { + self + } + + fn set_borders(&mut self, borders: Borders) { + self.borders = borders; + } + + fn set_joins(&mut self, joins: Borders) { + self.joins = joins; + } +} + +impl InteractableElement for GraphCard { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn as_element(&self) -> &dyn Element { + self + } + + fn as_element_mut(&mut self) -> &mut dyn Element { + self + } + + fn interact(&mut self, _key: KeyEvent) -> InteractionResult { + InteractionResult::Handled + } + + fn can_focus(&self) -> bool { + true + } + + fn is_focused(&self) -> bool { + self.focused + } + + fn focus(&mut self, f: bool) { + self.focused = f; + } +} diff --git a/iota-cli/src/elements/log_card.rs b/src/gui/elements/log_card.rs similarity index 59% rename from iota-cli/src/elements/log_card.rs rename to src/gui/elements/log_card.rs index a7d72eb..e2d4ba1 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/src/gui/elements/log_card.rs @@ -1,110 +1,96 @@ -use crate::elements::elements::{Element, InteractableElement, JoinableElement}; -use crate::util::borders::draw_block_joins; -use crate::{interaction_result::InteractionResult, render_context::RenderContext}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use iota_state::{ClientState, UiLogEntry}; +use crate::APP_STATE; +use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement}; +use crate::gui::interaction_result::InteractionResult; +use crate::gui::util::borders::draw_block_joins; +use crate::util::logger::PrintType; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, - style::Style, + style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::{ - any::Any, - sync::atomic::{AtomicUsize, Ordering}, -}; -use unicode_width::UnicodeWidthChar; +use std::any::Any; +use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Clone, Copy)] -enum LogSource { - Call, - Client, - Iota, - Omikron, - Omega, - Command, - Other, +#[derive(Clone, Debug)] +pub struct UiLogEntry { + pub timestamp_ms: u128, + pub sender: PrintType, + pub message: String, + pub is_error: bool, } -impl LogSource { - fn from_sender(sender: &str) -> Self { - match sender { - "Call" => Self::Call, - "Client" => Self::Client, - "Iota" => Self::Iota, - "Omikron" => Self::Omikron, - "Omega" => Self::Omega, - "Command" => Self::Command, - _ => Self::Other, +impl UiLogEntry { + pub fn format_timestamp(&self) -> String { + let secs = (self.timestamp_ms / 1000) as i64; + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } +} + +impl From for UiLogEntry { + fn from(entry: LogEntry) -> Self { + Self { + timestamp_ms: entry.timestamp_ms, + sender: entry.sender, + message: entry.message, + is_error: entry.is_error, } } +} - fn style(self, theme: &crate::theme::ResolvedTheme) -> Style { - match self { - Self::Call => theme.logs.call, - Self::Client => theme.logs.client, - Self::Iota => theme.logs.iota, - Self::Omikron => theme.logs.omikron, - Self::Omega => theme.logs.omega, - Self::Command => theme.logs.command, - Self::Other => theme.logs.other, +#[derive(Clone, Debug)] +pub struct LogEntry { + pub timestamp_ms: u128, + pub sender: PrintType, + pub message: String, + pub is_error: bool, +} + +impl LogEntry { + pub fn new(sender: PrintType, message: String, is_error: bool) -> Self { + Self { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + sender, + message, + is_error, } } } pub struct LogCard { - state: ClientState, focused: bool, selected: bool, scroll_offset: usize, - last_total_lines: AtomicUsize, - last_visible_height: AtomicUsize, - last_width: AtomicUsize, - filter: String, - filtering: bool, + last_total_lines: usize, + last_visible_height: usize, pub borders: Borders, pub joins: Borders, } impl LogCard { - pub fn new(state: ClientState) -> Self { + pub fn new() -> Self { Self { - state, focused: false, selected: false, scroll_offset: 0, - last_total_lines: AtomicUsize::new(0), - last_visible_height: AtomicUsize::new(1), - last_width: AtomicUsize::new(1), - filter: String::new(), - filtering: false, + last_total_lines: 0, + last_visible_height: 10, borders: Borders::ALL, joins: Borders::NONE, } } fn get_logs(&self) -> Vec { - let state = match self.state.app.try_lock() { - Ok(state) => state, - Err(_) => return Vec::new(), - }; - let needle = self.filter.to_ascii_lowercase(); - state - .get_logs() - .iter() - .filter(|entry| { - needle.is_empty() - || entry.sender.to_ascii_lowercase().contains(&needle) - || entry.message.to_ascii_lowercase().contains(&needle) - }) - .map(|e| UiLogEntry { - timestamp_ms: e.timestamp_ms, - sender: e.sender.clone(), - message: e.message.clone(), - is_error: e.is_error, - }) - .collect() + let state = APP_STATE.lock().unwrap(); + state.get_logs().iter().cloned().collect() } fn find_split_point(s: &str, max_width: usize) -> usize { @@ -116,7 +102,7 @@ impl LogCard { let mut last_boundary = 0usize; for (idx, ch) in s.char_indices() { - let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); + let char_width = if ch.is_ascii() { 1 } else { 2 }; if current_width + char_width > max_width { if last_boundary == 0 { return idx + ch.len_utf8(); @@ -130,7 +116,7 @@ impl LogCard { s.len() } - fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> { + fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> { let mut result = Vec::new(); let timestamp = entry.format_timestamp(); @@ -193,7 +179,7 @@ impl LogCard { line.push_str(×tamp); } - result.push((line, LogSource::from_sender(&entry.sender), entry.is_error)); + result.push((line, entry.sender.prefix_color(), entry.is_error)); } result @@ -203,7 +189,7 @@ impl LogCard { &self, entries: Vec, width: usize, - ) -> Vec<(String, LogSource, bool)> { + ) -> Vec<(String, Color, bool)> { let mut lines = Vec::new(); for entry in entries { @@ -229,13 +215,11 @@ impl LogCard { } fn get_title_hints(&self) -> (bool, bool) { - let total_lines = self.last_total_lines.load(Ordering::Relaxed); - let visible_height = self.last_visible_height.load(Ordering::Relaxed); - if total_lines == 0 || total_lines <= visible_height { + if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { return (false, false); } - let max_offset = total_lines - visible_height; + let max_offset = self.last_total_lines - self.last_visible_height; let can_scroll_up = self.scroll_offset < max_offset; let can_scroll_down = self.scroll_offset > 0; @@ -243,12 +227,6 @@ impl LogCard { } fn build_title(&self) -> String { - if self.filtering { - return format!("Logs filter: {}_", self.filter); - } - if !self.filter.is_empty() { - return format!("Logs [filter: {}]", self.filter); - } if !self.focused { return "Logs".to_string(); } @@ -273,8 +251,7 @@ impl LogCard { fn scroll_up(&mut self) { let max_offset = self .last_total_lines - .load(Ordering::Relaxed) - .saturating_sub(self.last_visible_height.load(Ordering::Relaxed)); + .saturating_sub(self.last_visible_height); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } @@ -318,92 +295,61 @@ impl Element for LogCard { self } - fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, area: Rect) { let entries = self.get_logs(); - let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &self.build_title(), - self.focused, - context.theme, - ) - } else { - let block = Block::default() - .title(self.build_title()) - .borders(self.borders) - .border_style(if self.focused { - context.theme.logs.focused_border - } else { - context.theme.logs.border - }); - let inner = block.inner(area); - f.render_widget(block, area); - inner - }; + let block = Block::default() + .title(self.build_title()) + .borders(self.borders) + .border_style(if self.focused { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }); + + let inner_area = block.inner(area); + f.render_widget(block, area); if inner_area.width == 0 || inner_area.height == 0 { - draw_block_joins( - f, - area, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); + draw_block_joins(f, area, self.borders, self.joins); return; } let all_lines = self.build_all_lines(entries, inner_area.width as usize); let total_lines = all_lines.len(); let visible_height = inner_area.height as usize; - self.last_width - .store(inner_area.width as usize, Ordering::Relaxed); - self.last_total_lines.store(total_lines, Ordering::Relaxed); - self.last_visible_height - .store(visible_height, Ordering::Relaxed); let (start, end) = self.calculate_view_window(total_lines, visible_height); let visible_lines = &all_lines[start..end]; let rendered_lines: Vec = visible_lines .iter() - .map(|(line, source, is_error)| { + .map(|(line, prefix_color, is_error)| { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); - let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - "" - } else { - prefix - }; if !prefix.is_empty() { spans.push(Span::styled( prefix.to_string(), - source.style(context.theme), + Style::default().fg(*prefix_color), )); } let (content, timestamp) = Self::split_timestamp_suffix(rest); - let text_style = if *is_error { - context.theme.logs.error - } else { - context.theme.logs.text - }; + let text_color = if *is_error { Color::Red } else { Color::White }; if !content.is_empty() { - spans.push(Span::styled(content.to_string(), text_style)); + spans.push(Span::styled( + content.to_string(), + Style::default().fg(text_color), + )); } if !timestamp.is_empty() { spans.push(Span::styled( timestamp.to_string(), - context.theme.logs.timestamp, + Style::default().fg(Color::DarkGray), )); } @@ -421,19 +367,7 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - draw_block_joins( - f, - area, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); - } + draw_block_joins(f, area, self.borders, self.joins); } } @@ -481,44 +415,14 @@ impl InteractableElement for LogCard { } fn interact(&mut self, key: KeyEvent) -> InteractionResult { - if self.filtering { - match key.code { - KeyCode::Esc => { - self.filtering = false; - self.filter.clear(); - } - KeyCode::Enter => self.filtering = false, - KeyCode::Backspace => { - self.filter.pop(); - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.filter.push(c); - } - _ => {} - } - self.scroll_offset = 0; - return InteractionResult::Handled; - } let entries = self.get_logs(); - let width = self.last_width.load(Ordering::Relaxed).max(1); - let all_lines = self.build_all_lines(entries, width); + let estimated_width = 80usize; + let all_lines = self.build_all_lines(entries, estimated_width); - self.last_total_lines - .store(all_lines.len(), Ordering::Relaxed); - let total_lines = all_lines.len(); - let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1); + self.last_total_lines = all_lines.len(); + let visible_height = self.last_visible_height.max(1); match key.code { - KeyCode::Char('/') => { - self.filtering = true; - self.filter.clear(); - self.scroll_offset = 0; - InteractionResult::Handled - } KeyCode::Enter | KeyCode::Char(' ') => { self.selected = !self.selected; InteractionResult::Handled @@ -552,8 +456,8 @@ impl InteractableElement for LogCard { InteractionResult::Handled } KeyCode::Home => { - if total_lines > visible_height { - self.scroll_offset = total_lines - visible_height; + if self.last_total_lines > visible_height { + self.scroll_offset = self.last_total_lines - visible_height; } InteractionResult::Handled } diff --git a/src/gui/input_handler.rs b/src/gui/input_handler.rs new file mode 100644 index 0000000..b2557c5 --- /dev/null +++ b/src/gui/input_handler.rs @@ -0,0 +1,56 @@ +use crate::gui::ui::{UI, UNIQUE}; +use crate::{RELOAD, SHUTDOWN}; +use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +pub fn setup_input_handler(ui: Arc) { + tokio::spawn(async move { + loop { + if *SHUTDOWN.read().await { + break; + } + + let event_result = tokio::task::spawn_blocking(|| { + if let Ok(true) = poll(Duration::from_millis(100)) { + read().ok().and_then(|ev| match ev { + Event::Key(key) if key.kind == KeyEventKind::Press => Some(key), + _ => None, + }) + } else { + None + } + }) + .await; + + match event_result { + Ok(Some(key_event)) => { + handle_input(key_event, ui.clone()).await; + UNIQUE.store(true, Ordering::Relaxed); + } + Ok(_) => {} + Err(e) => { + eprintln!("Input task error: {}", e); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } + }); +} + +pub async fn handle_input(key: KeyEvent, ui: Arc) { + match (key.code, key.modifiers) { + (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) + | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { + *SHUTDOWN.write().await = true; + } + (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { + *RELOAD.write().await = true; + *SHUTDOWN.write().await = true; + } + _ => { + ui.handle_input(key).await; + } + } +} diff --git a/iota-cli/src/interaction_result.rs b/src/gui/interaction_result.rs similarity index 82% rename from iota-cli/src/interaction_result.rs rename to src/gui/interaction_result.rs index 8afad00..462149a 100644 --- a/iota-cli/src/interaction_result.rs +++ b/src/gui/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::screens::screens::{Screen, UiEvent}; +use crate::gui::screens::screens::Screen; #[allow(unused)] pub enum InteractionResult { @@ -13,9 +13,6 @@ pub enum InteractionResult { OpenFutureScreen { screen: Pin> + Send>>, }, - AppTask { - task: Pin + Send>>, - }, Handled, Unhandled, } @@ -25,7 +22,6 @@ impl Debug for InteractionResult { match self { InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), - InteractionResult::AppTask { task: _ } => write!(f, "AppTask"), InteractionResult::CloseScreen => write!(f, "CloseScreen"), InteractionResult::Handled => write!(f, "Handled"), InteractionResult::Unhandled => write!(f, "Unhandled"), @@ -40,9 +36,6 @@ impl PartialEq for InteractionResult { InteractionResult::OpenScreen { screen: _ }, InteractionResult::OpenScreen { screen: _ }, ) => true, - (InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => { - true - } ( InteractionResult::OpenFutureScreen { screen: _ }, InteractionResult::OpenFutureScreen { screen: _ }, diff --git a/iota-cli/src/lib.rs b/src/gui/mod.rs similarity index 54% rename from iota-cli/src/lib.rs rename to src/gui/mod.rs index bd2e44d..b355046 100644 --- a/iota-cli/src/lib.rs +++ b/src/gui/mod.rs @@ -5,31 +5,16 @@ pub mod elements { pub mod log_card; } pub mod screens { - pub mod daemon_setup; pub mod main_screen; pub mod md_viewer; - pub mod metrics; - pub mod overview; pub mod screens; - pub mod settings; pub mod terms_checker; pub mod terms_updater; - pub mod users; } pub mod util { pub mod borders; - pub mod buttons; - pub mod terms_focus; } pub mod app_state; -pub mod controls; -pub mod help_overlay; pub mod input_handler; pub mod interaction_result; -pub mod ipc_client; -pub mod layout; -pub mod notification; -pub mod render_context; -pub mod theme; pub mod ui; -pub use ui::TuiSession; diff --git a/src/gui/screens/main_screen.rs b/src/gui/screens/main_screen.rs new file mode 100644 index 0000000..85dbbaa --- /dev/null +++ b/src/gui/screens/main_screen.rs @@ -0,0 +1,242 @@ +use crate::gui::{ + elements::{ + console_card::ConsoleCard, + elements::{InteractableElement, JoinableElement}, + graph_card::{GRAPHS, GraphCard}, + log_card::LogCard, + }, + interaction_result::InteractionResult, + screens::screens::{NavDirection, Screen}, + ui::UI, +}; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Margin, Rect}, + widgets::{Block, Borders}, +}; + +use std::{any::Any, sync::Arc}; + +pub struct MainScreen { + elements: Vec>, + nav_grid: Vec>>, + selected_coords: (usize, usize), + graphs_open: bool, +} + +impl MainScreen { + pub async fn new(ui: Arc) -> Self { + let mut elements: Vec> = Vec::new(); + + let nav_grid = vec![ + vec![Some(0), Some(2)], + vec![Some(0), Some(3)], + vec![Some(1), Some(4)], + ]; + + let mut log_card = LogCard::new(); + log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); + let mut console_card = ConsoleCard::new("Console", ""); + console_card.set_joins(Borders::TOP); + + elements.push(Box::new(log_card)); + elements.push(Box::new(console_card)); + + let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM".into()); + ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); + elements.push(Box::new(ram_graph)); + let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU".into()); + cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); + cpu_graph.set_joins(Borders::TOP); + elements.push(Box::new(cpu_graph)); + let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into()); + ping_graph.set_joins(Borders::TOP); + elements.push(Box::new(ping_graph)); + + let graphs_open = true; + + let mut screen = MainScreen { + elements, + nav_grid, + selected_coords: (1, 0), + graphs_open, + }; + screen.focus_current(); + screen + } + + fn focus_current(&mut self) { + let (y, x) = self.selected_coords; + if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { + if let Some(element) = self.elements.get_mut(*index) { + if element.can_focus() { + element.focus(true); + } + } + } + } + + fn unfocus_current(&mut self, y: usize, x: usize) { + if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { + if let Some(element) = self.elements.get_mut(*index) { + element.focus(false); + } + } + } + + fn navigate(&mut self, direction: NavDirection) { + let (current_row, current_col) = self.selected_coords; + let current_element = self.nav_grid[current_row][current_col]; + + self.unfocus_current(current_row, current_col); + + let (delta_row, delta_col) = match direction { + NavDirection::Up => (-1isize, 0), + NavDirection::Down => (1, 0), + NavDirection::Left => (0, -1), + NavDirection::Right => (0, 1), + _ => (0, 0), + }; + + let mut next_row = current_row as isize; + let mut next_col = current_col as isize; + + loop { + next_row += delta_row; + next_col += delta_col; + + if next_row < 0 || next_col < 0 { + self.selected_coords = ( + (next_row - delta_row) as usize, + (next_col - delta_col) as usize, + ); + break; + } + let next_row_u = next_row as usize; + let next_col_u = next_col as usize; + + if next_row_u >= self.nav_grid.len() { + self.selected_coords = ( + (next_row - delta_row) as usize, + (next_col - delta_col) as usize, + ); + break; + } + + if let Some(row) = self.nav_grid.get(next_row_u) { + if next_col_u >= row.len() { + self.selected_coords = ( + (next_row - delta_row) as usize, + (next_col - delta_col) as usize, + ); + break; + } + + if let Some(next_element) = row[next_col_u] { + if Some(next_element) != current_element { + self.selected_coords = (next_row_u, next_col_u); + self.focus_current(); + return; + } + } + } + } + + self.focus_current(); + } +} + +impl Screen for MainScreen { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect) { + let main_block = Block::default().title("Main").borders(Borders::ALL); + f.render_widget(main_block, rect); + + let inner = rect.inner(Margin { + vertical: 1, + horizontal: 1, + }); + + let graphs_width = if self.graphs_open { 30 } else { 2 }; + let main_width = inner.width.saturating_sub(graphs_width); + + let horizontal_chunks = Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + Constraint::Length(main_width), + Constraint::Length(graphs_width), + ]) + .split(inner); + + let left_area = horizontal_chunks[0]; + let right_area = horizontal_chunks[1]; + + let left_rows = + Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); + + if let Some(log) = self.elements.get(0) { + log.as_element().render(f, left_rows[0]); + } + + if let Some(console) = self.elements.get(1) { + console.as_element().render(f, left_rows[1]); + } + + let graph_elements: Vec<_> = self + .elements + .iter() + .filter(|el| el.as_any().is::()) + .collect(); + + if !graph_elements.is_empty() { + let graph_chunks = Layout::vertical( + graph_elements + .iter() + .map(|_| Constraint::Ratio(1, graph_elements.len() as u32)) + .collect::>(), + ) + .split(right_area); + + for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) { + el.as_element().render(f, *area); + } + } + } + + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + match event.code { + KeyCode::Up => self.navigate(NavDirection::Up), + KeyCode::Down => self.navigate(NavDirection::Down), + KeyCode::Left => self.navigate(NavDirection::Left), + KeyCode::Right => self.navigate(NavDirection::Right), + KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { + self.graphs_open = !self.graphs_open; + for element in self.elements.iter_mut() { + if let Some(graph) = element.as_any_mut().downcast_mut::() { + graph.set_open(self.graphs_open); + } + } + return InteractionResult::Handled; + } + _ => { + let (y, x) = self.selected_coords; + if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) { + if let Some(el) = self.elements.get_mut(*index) { + return el.interact(event); + } + } + } + } + + InteractionResult::Handled + } +} diff --git a/iota-cli/src/screens/md_viewer.rs b/src/gui/screens/md_viewer.rs similarity index 80% rename from iota-cli/src/screens/md_viewer.rs rename to src/gui/screens/md_viewer.rs index 8c46d3d..10eabce 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/src/gui/screens/md_viewer.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; use ratatui::{ DefaultTerminal, prelude::*, @@ -7,16 +7,11 @@ use ratatui::{ }; use std::{any::Any, time::Duration}; -use crate::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, - theme::{ResolvedTheme, TextSemantics, ThemeName}, -}; +use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; pub struct FileViewer { title: String, - content: String, + text: Vec, scroll: u16, scroll_x: u16, } @@ -29,14 +24,11 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - self.draw(f, rect, context.theme); + fn render(&self, f: &mut Frame, rect: Rect) { + self.draw(f, rect); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; @@ -60,7 +52,7 @@ impl FileViewer { pub fn new(title: String, content: &str) -> Self { Self { title, - content: content.to_owned(), + text: parse_document(content.to_owned()), scroll: 0, scroll_x: 0, } @@ -70,7 +62,7 @@ impl FileViewer { terminal .draw(|f| { let area = f.area(); - self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi)); + self.draw(f, area); }) .unwrap(); @@ -85,13 +77,12 @@ impl FileViewer { } terminal } - fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) { + fn draw(&self, f: &mut Frame, area: Rect) { use ratatui::text::Text; let mut rendered_lines = Vec::new(); - let text = parse_document(&self.content, theme); - for display_line in &text { + for display_line in &self.text { if display_line.scrollable { let content: String = display_line .line @@ -162,7 +153,7 @@ impl FileViewer { } } } -fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { +fn parse_document(input: String) -> Vec { let mut lines_vec = Vec::new(); let mut in_code_block = false; let liness: Vec = input.lines().map(String::from).collect(); @@ -181,7 +172,7 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( format!("────────{}────────", code), - theme.markdown.divider, + Style::default().fg(Color::DarkGray), )), scrollable: false, }); @@ -191,7 +182,10 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { if in_code_block { lines_vec.push(DisplayLine { - line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)), + line: Line::from(Span::styled( + raw.to_string(), + Style::default().fg(Color::Yellow), + )), scrollable: false, }); i += 1; @@ -201,13 +195,9 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("### ").to_string(), - theme.apply_text_semantics( - theme.markdown.heading, - TextSemantics { - bold: true, - underline: false, - }, - ), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )), scrollable: false, }); @@ -218,13 +208,9 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("## ").to_string(), - theme.apply_text_semantics( - theme.markdown.heading, - TextSemantics { - bold: true, - underline: false, - }, - ), + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), )), scrollable: false, }); @@ -235,13 +221,9 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("# ").to_string(), - theme.apply_text_semantics( - theme.markdown.heading, - TextSemantics { - bold: true, - underline: false, - }, - ), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), )), scrollable: false, }); @@ -272,13 +254,13 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { } let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::>()); - lines_vec.extend(table_to_lines(table, theme)); + lines_vec.extend(table_to_lines(table)); i = j; continue; } lines_vec.push(DisplayLine { - line: Line::from(parse_inline(raw.as_str(), theme)), + line: Line::from(parse_inline(raw.as_str())), scrollable: false, }); i += 1; @@ -287,7 +269,7 @@ fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { lines_vec } -fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { +fn parse_inline(input: &str) -> Vec> { let mut spans = Vec::new(); let mut buf = String::new(); @@ -312,11 +294,7 @@ fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { }; if let Some(kind) = toggle { - flush_span( - &mut spans, - &mut buf, - current_style(bold, underline, code, theme), - ); + flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); match kind { "bold" => bold = !bold, @@ -330,21 +308,24 @@ fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { buf.push(c); } - flush_span( - &mut spans, - &mut buf, - current_style(bold, underline, code, theme), - ); + flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); spans } -fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style { - let base = if code { - theme.markdown.code - } else { - theme.markdown.normal - }; - theme.apply_text_semantics(base, TextSemantics { bold, underline }) +fn current_style(bold: bool, underline: bool, code: bool) -> Style { + let mut style = Style::default(); + + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if underline { + style = style.add_modifier(Modifier::UNDERLINED); + } + if code { + style = style.fg(Color::Yellow); + } + + style } #[derive(Clone)] pub struct DisplayLine { @@ -352,7 +333,7 @@ pub struct DisplayLine { scrollable: bool, } -fn table_to_lines(table: Vec>, theme: &ResolvedTheme) -> Vec { +fn table_to_lines(table: Vec>) -> Vec { if table.len() < 2 { return vec![]; } @@ -396,7 +377,7 @@ fn table_to_lines(table: Vec>, theme: &ResolvedTheme) -> Vec>, theme: &ResolvedTheme) -> Vec &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; + + fn render(&self, f: &mut Frame, rect: Rect); + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; +} diff --git a/iota-cli/src/screens/terms_checker.rs b/src/gui/screens/terms_checker.rs similarity index 72% rename from iota-cli/src/screens/terms_checker.rs rename to src/gui/screens/terms_checker.rs index 885bd65..26bb7c6 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/src/gui/screens/terms_checker.rs @@ -1,32 +1,29 @@ use crate::{ - controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::{ - md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, + gui::{ + interaction_result::InteractionResult, + screens::{md_viewer::FileViewer, screens::Screen}, + ui::UI, + }, + terms::{ + buttons::{checkbox, draw_buttons}, + consent_state::UserChoice, + focus::Focus, + terms_getter::{Type, get_link, get_terms}, }, - util::{buttons::draw_buttons, terms_focus::Focus}, }; -use crossterm::event::KeyCode; -use iota_terms::{TermsType, get_link, get_terms}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; -use std::{any::Any, pin::Pin}; +use std::{any::Any, pin::Pin, sync::Arc}; use tokio::sync::oneshot; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UserChoice { - Deny, - AcceptEULA, - AcceptAll, -} - pub struct TermsCheckerScreen { + ui: Arc, sender: Option>, eula: bool, @@ -37,8 +34,9 @@ pub struct TermsCheckerScreen { } impl TermsCheckerScreen { - pub fn new(sender: Option>) -> Self { + pub fn new(ui: Arc, sender: Option>) -> Self { Self { + ui, sender, eula: false, tos: false, @@ -56,7 +54,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render(&self, f: &mut Frame, size: Rect) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -94,19 +92,19 @@ impl Screen for TermsCheckerScreen { height: content_height, }); let eula_text = if size.width < 70 { - "EULA ¹ (https://legal.methanium.net/tensamin/eula)" + "EULA ¹ (https://legal.tensamin.net/eula/)" } else { - "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)" + "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" }; let tos_text = if size.width < 72 { - "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)" + "ToS ² (https://legal.tensamin.net/terms-of-service/)" } else { - "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)" + "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" }; let pp_text = if size.width < 68 { - "PP ² (https://legal.methanium.net/tensamin/privacy-policy)" + "PP ² (https://legal.tensamin.net/privacy-policy/)" } else { - "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)" + "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" }; let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = if size.width > 143 { @@ -166,36 +164,9 @@ impl Screen for TermsCheckerScreen { ) }; let mut text_lines = vec![ - render_choice_line( - eula_text, - ChoiceKind::Checkbox, - ChoiceVisualState { - selected: self.eula, - focused: self.focus == Focus::Eula, - enabled: true, - }, - context.theme, - ), - render_choice_line( - tos_text, - ChoiceKind::Checkbox, - ChoiceVisualState { - selected: self.tos, - focused: self.focus == Focus::Tos, - enabled: self.eula, - }, - context.theme, - ), - render_choice_line( - pp_text, - ChoiceKind::Checkbox, - ChoiceVisualState { - selected: self.pp, - focused: self.focus == Focus::Pp, - enabled: self.eula, - }, - context.theme, - ), + checkbox(eula_text, self.eula, self.focus == Focus::Eula, true), + checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula), + checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula), Line::from(""), Line::from("¹ Necessary– required to run the program"), Line::from("² Optional – required only for Tensamin services"), @@ -215,19 +186,19 @@ impl Screen for TermsCheckerScreen { if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - context.theme.status.success + Style::default().fg(Color::Green) } else if size.width >= 60 { - context.theme.status.warning + Style::default().fg(Color::Yellow) } else { - context.theme.status.error + Style::default().fg(Color::Red) }; let height_style = if size.height > 19 { - context.theme.status.success + Style::default().fg(Color::Green) } else if size.height >= 13 { - context.theme.status.warning + Style::default().fg(Color::Yellow) } else { - context.theme.status.error + Style::default().fg(Color::Red) }; let warning_text = Text::from(vec![ @@ -269,14 +240,10 @@ impl Screen for TermsCheckerScreen { true, false, true, - context.theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { @@ -309,27 +276,19 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(TermsType::EULA), - Focus::Tos => Some(TermsType::TOS), - Focus::Pp => Some(TermsType::PP), + Focus::Eula => Some(Type::EULA), + Focus::Tos => Some(Type::TOS), + Focus::Pp => Some(Type::PP), _ => None, }; if let Some(terms_type) = terms_type { - let fut: Pin> + Send>> = Box::pin( - async move { - if let Some(content) = get_terms(terms_type.clone()).await { - let screen: FileViewer = - FileViewer::new(terms_type.to_string(), &content); - Box::new(screen) as Box - } else { - let screen: FileViewer = FileViewer::new( - "Error".to_string(), - "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", - ); - Box::new(screen) as Box - } - }, - ); + let fut: Pin> + Send>> = + Box::pin(async move { + let content = get_terms(terms_type.clone()).await.unwrap(); + let screen: FileViewer = + FileViewer::new(terms_type.to_string(), &content); + Box::new(screen) as Box + }); InteractionResult::OpenFutureScreen { screen: fut } } else { InteractionResult::Unhandled @@ -337,15 +296,15 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_link(TermsType::EULA)); + let _ = open::that(get_link(Type::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_link(TermsType::TOS)); + let _ = open::that(get_link(Type::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_link(TermsType::PP)); + let _ = open::that(get_link(Type::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/iota-cli/src/screens/terms_updater.rs b/src/gui/screens/terms_updater.rs similarity index 87% rename from iota-cli/src/screens/terms_updater.rs rename to src/gui/screens/terms_updater.rs index 21e3247..35da364 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/src/gui/screens/terms_updater.rs @@ -1,33 +1,28 @@ -use crate::screens::terms_checker::UserChoice; use crate::{ - controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::{ - md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, + gui::{ + interaction_result::InteractionResult, + screens::{md_viewer::FileViewer, screens::Screen}, + }, + terms::{ + buttons::{checkbox, draw_buttons}, + consent_state::{UpdateDecision, UserChoice}, + doc::Doc, + focus::Focus, + terms_getter::{Type, get_newest_link, get_terms}, }, - util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; -use crossterm::event::KeyCode; -use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; use std::any::Any; use tokio::sync::oneshot; -#[derive(Debug, Clone)] -pub enum UpdateDecision { - NoChange, - Future { newest: Doc }, - Forced(Doc), -} - pub struct TermsUpdaterScreen { sender: Option>, @@ -124,19 +119,7 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let checkbox = |label, selected, focused, enabled| { - render_choice_line( - label, - ChoiceKind::Checkbox, - ChoiceVisualState { - selected, - focused, - enabled, - }, - context.theme, - ) - }; + fn render(&self, f: &mut Frame, size: Rect) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -201,7 +184,7 @@ impl Screen for TermsUpdaterScreen { if self.eula_future { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹³ (https://legal.methanium.net/tensamin/eula)", + "EULA ¹³ (https://legal.tensamin.net/eula/newest/)", self.eula, self.focus == Focus::Eula, true, @@ -214,7 +197,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "End User Licence Agreement ¹³ (https://legal.methanium.net/tensamin/eula)", + "End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)", self.eula, self.focus == Focus::Eula, true, @@ -229,14 +212,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹ (https://legal.methanium.net/tensamin/eula)", + "EULA ¹ (https://legal.tensamin.net/eula/newest/)", self.eula, self.focus == Focus::Eula, true, )); } else { text_lines.push(checkbox( - "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)", + "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", self.eula, self.focus == Focus::Eula, true, @@ -250,7 +233,7 @@ impl Screen for TermsUpdaterScreen { if self.tos_future { if size.width < 80 { text_lines.push(checkbox( - "ToS ²³ (https://legal.methanium.net/tensamin/terms-of-service)", + "ToS ²³ (https://legal.tensamin.net/tos/newest/)", self.tos, self.focus == Focus::Tos, self.eula, @@ -263,7 +246,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "Terms of Service ²³ (https://legal.methanium.net/tensamin/terms-of-service)", + "Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)", self.tos, self.focus == Focus::Tos, self.eula, @@ -278,14 +261,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)", + "ToS ² (https://legal.tensamin.net/tos/newest/)", self.tos, self.focus == Focus::Tos, self.eula, )); } else { text_lines.push(checkbox( - "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)", + "Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", self.tos, self.focus == Focus::Tos, self.eula, @@ -299,7 +282,7 @@ impl Screen for TermsUpdaterScreen { if self.pp_future { if size.width < 80 { text_lines.push(checkbox( - "PP ²³ (https://legal.methanium.net/tensamin/privacy-policy)", + "PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", self.pp, self.focus == Focus::Pp, self.eula, @@ -312,7 +295,7 @@ impl Screen for TermsUpdaterScreen { text_lines.push(Line::from(format!(" Goes into effect on {}", date))); } else { text_lines.push(checkbox( - "Privacy Policy ²³ (https://legal.methanium.net/tensamin/privacy-policy)", + "Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)", self.pp, self.focus == Focus::Pp, self.eula, @@ -327,14 +310,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "PP ² (https://legal.methanium.net/tensamin/privacy-policy)", + "PP ² (https://legal.tensamin.net/privacy-policy/newest/)", self.pp, self.focus == Focus::Pp, self.eula, )); } else { text_lines.push(checkbox( - "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)", + "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", self.pp, self.focus == Focus::Pp, self.eula, @@ -536,19 +519,19 @@ impl Screen for TermsUpdaterScreen { }; if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - context.theme.status.success + Style::default().fg(Color::Green) } else if size.width >= 60 { - context.theme.status.warning + Style::default().fg(Color::Yellow) } else { - context.theme.status.error + Style::default().fg(Color::Red) }; let height_style = if size.height > 20 { - context.theme.status.success + Style::default().fg(Color::Green) } else if size.height >= (header_lines as u16 + 10) { - context.theme.status.warning + Style::default().fg(Color::Yellow) } else { - context.theme.status.error + Style::default().fg(Color::Red) }; let warning_text = Text::from(vec![ @@ -592,14 +575,10 @@ impl Screen for TermsUpdaterScreen { self.update_needed, downgrade_scenario, self.pp_needed || self.tos_needed, - context.theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { let mut possible_states = Vec::new(); if self.eula_needed { @@ -665,23 +644,17 @@ impl Screen for TermsUpdaterScreen { }, KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(TermsType::EULA), - Focus::Tos => Some(TermsType::TOS), - Focus::Pp => Some(TermsType::PP), + Focus::Eula => Some(Type::EULA), + Focus::Tos => Some(Type::TOS), + Focus::Pp => Some(Type::PP), _ => None, }; if let Some(terms_type) = terms_type { let fut = Box::pin(async move { - if let Some(content) = get_terms(terms_type.clone()).await { - Box::new(FileViewer::new(terms_type.to_string(), &content)) - as Box - } else { - Box::new(FileViewer::new( - "Error".to_string(), - "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", - )) as Box - } + let content = get_terms(terms_type.clone()).await.unwrap(); + Box::new(FileViewer::new(terms_type.to_string(), &content)) + as Box }); return InteractionResult::OpenFutureScreen { screen: fut }; @@ -691,15 +664,15 @@ impl Screen for TermsUpdaterScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_newest_link(TermsType::EULA)); + let _ = open::that(get_newest_link(Type::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_newest_link(TermsType::TOS)); + let _ = open::that(get_newest_link(Type::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_newest_link(TermsType::PP)); + let _ = open::that(get_newest_link(Type::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/iota-cli/src/tui.rs b/src/gui/tui.rs similarity index 100% rename from iota-cli/src/tui.rs rename to src/gui/tui.rs diff --git a/src/gui/ui.rs b/src/gui/ui.rs new file mode 100644 index 0000000..5903a4b --- /dev/null +++ b/src/gui/ui.rs @@ -0,0 +1,167 @@ +use crate::{ + ACTIVE_TASKS, SHUTDOWN, + gui::{ + input_handler::setup_input_handler, interaction_result::InteractionResult, + screens::screens::Screen, + }, +}; +use crossterm::event::KeyEvent; +use once_cell::sync::Lazy; +use ratatui::{Terminal, backend::CrosstermBackend, init}; +use std::{ + collections::VecDeque, + io::Stdout, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::{sync::RwLock, time::Instant}; + +/// UI state and rendering +pub static UNIQUE: AtomicBool = AtomicBool::new(true); + +pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); + +pub struct UI { + pub terminal: Arc>>>, + screen_stack: Arc>>>, +} + +pub fn start_tui() -> Arc { + let ui = Arc::new(UI::new()); + let uic = ui.clone(); + ACTIVE_TASKS.insert("UI Renderer".to_string()); + tokio::spawn(async move { + let mut last_render = Instant::now(); + + let mut fps_samples: VecDeque = VecDeque::with_capacity(20); + let mut skip_samples: VecDeque = VecDeque::with_capacity(20); + + let mut fps_sum = 0.0; + let mut skip_sum: u32 = 0; + + let mut skipped = 0; + + loop { + if *SHUTDOWN.read().await { + break; + } + + if skipped > 5 || UNIQUE.load(Ordering::Relaxed) { + uic.render().await; + + skip_samples.push_back(skipped); + skip_sum += skipped as u32; + + if skip_samples.len() > 20 { + if let Some(old) = skip_samples.pop_front() { + skip_sum -= old as u32; + } + } + + skipped = 0; + + let elapsed = last_render.elapsed().as_secs_f64(); + if elapsed > 0.0 { + let fps = 1.0 / elapsed; + + fps_samples.push_back(fps); + fps_sum += fps; + + if fps_samples.len() > 20 { + if let Some(old) = fps_samples.pop_front() { + fps_sum -= old; + } + } + } + + let avg_fps = if !fps_samples.is_empty() { + fps_sum / fps_samples.len() as f64 + } else { + 0.0 + }; + + let avg_skips_percentage = if !skip_samples.is_empty() { + let avg_skipped = skip_sum as f64 / skip_samples.len() as f64; + let total_iterations = avg_skipped + 1.0; + (avg_skipped / total_iterations) * 100.0 + } else { + 0.0 + }; + + *FPS.write().await = (avg_fps, avg_skips_percentage); + + last_render = Instant::now(); + UNIQUE.store(false, Ordering::Relaxed); + } else { + skipped += 1; + } + tokio::time::sleep(Duration::from_millis(16)).await; + } + ACTIVE_TASKS.remove("UI Renderer"); + ratatui::restore(); + }); + setup_input_handler(ui.clone()); + ui +} +impl UI { + pub fn new() -> Self { + let terminal = init(); + Self { + terminal: Arc::new(Mutex::new(terminal)), + screen_stack: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn set_screen(&self, screen: Box) { + self.screen_stack.write().await.push(screen); + } + pub async fn replace_screen(&self, screen: Box) { + let mut stack = self.screen_stack.write().await; + stack.pop(); + stack.push(screen); + } + pub async fn handle_input(self: Arc, key_event: KeyEvent) { + let result = { + let mut stack = self.screen_stack.write().await; + if let Some(screen) = stack.last_mut() { + screen.handle_input(key_event) + } else { + return; + } + }; + match result { + InteractionResult::OpenScreen { screen } => { + self.set_screen(screen).await; + } + InteractionResult::OpenFutureScreen { screen: fut } => { + let ui = self.clone(); + let screen = fut.await; + ui.set_screen(screen).await; + } + InteractionResult::CloseScreen => { + let mut stack = self.screen_stack.write().await; + stack.pop(); + + if stack.is_empty() { + *SHUTDOWN.write().await = true; + } + } + InteractionResult::Handled => {} + InteractionResult::Unhandled => {} + } + } + + pub async fn render(&self) { + if let Some(screen) = self.screen_stack.read().await.last() { + let mut terminal = self.terminal.lock().unwrap(); + terminal + .draw(|f| { + screen.render(f, f.area()); + }) + .unwrap(); + } + } +} diff --git a/iota-cli/src/util/borders.rs b/src/gui/util/borders.rs similarity index 74% rename from iota-cli/src/util/borders.rs rename to src/gui/util/borders.rs index a6d26a8..34460bc 100644 --- a/iota-cli/src/util/borders.rs +++ b/src/gui/util/borders.rs @@ -3,20 +3,13 @@ use ratatui::prelude::*; use ratatui::style::Style; use ratatui::widgets::Borders; -fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) { - frame.buffer_mut().set_string(x, y, c.to_string(), style); +fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) { + frame + .buffer_mut() + .set_string(x, y, c.to_string(), Style::default()); } -pub fn draw_block_joins( - frame: &mut Frame, - area: Rect, - borders: Borders, - joins: Borders, - style: Style, -) { - if area.width == 0 || area.height == 0 { - return; - } +pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) { let x0 = area.x; let y0 = area.y; let x1 = area.x + area.width - 1; @@ -29,7 +22,7 @@ pub fn draw_block_joins( (false, true) => '┬', (false, false) => '┌', }; - set_join_char(frame, x0, y0, top_left, style); + set_join_char(frame, x0, y0, top_left); } if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) { @@ -39,7 +32,7 @@ pub fn draw_block_joins( (false, true) => '┬', (false, false) => '┐', }; - set_join_char(frame, x1, y0, top_right, style); + set_join_char(frame, x1, y0, top_right); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) { @@ -52,7 +45,7 @@ pub fn draw_block_joins( (false, true) => '┴', (false, false) => '└', }; - set_join_char(frame, x0, y1, bottom_left, style); + set_join_char(frame, x0, y1, bottom_left); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) { @@ -65,6 +58,6 @@ pub fn draw_block_joins( (false, true) => '┴', (false, false) => '┘', }; - set_join_char(frame, x1, y1, bottom_right, style); + set_join_char(frame, x1, y1, bottom_right); } } diff --git a/iota-logger/src/language_creator.rs b/src/langu/language_creator.rs similarity index 98% rename from iota-logger/src/language_creator.rs rename to src/langu/language_creator.rs index 2b9f109..fc6d1c5 100644 --- a/iota-logger/src/language_creator.rs +++ b/src/langu/language_creator.rs @@ -1,5 +1,4 @@ -use iota_util::file_util::save_file; - +use crate::util::file_util::save_file; use json::{self, JsonError, JsonValue}; pub fn create_languages() -> Result<(), JsonError> { diff --git a/iota-logger/src/language_manager.rs b/src/langu/language_manager.rs similarity index 50% rename from iota-logger/src/language_manager.rs rename to src/langu/language_manager.rs index 2d3bda3..c903886 100644 --- a/iota-logger/src/language_manager.rs +++ b/src/langu/language_manager.rs @@ -1,4 +1,4 @@ -use iota_util::file_util::{self}; +use crate::util::file_util::{self}; use json::parse; use once_cell::sync::Lazy; use std::collections::HashMap; @@ -59,52 +59,42 @@ impl LanguagePack { } pub fn load_language(&mut self, language: &str) { - let files = [ - "frontend.json", - "omikron.json", - "buttons.json", - "debug.json", - "general.json", - ]; + let path = format!("languages/{}/", language); - // The daemon can initialize the logger before iota-core has created - // the generated language files. Create the built-in pack on demand. - if language == "en_INT" - && files - .iter() - .any(|file| !file_util::has_file(&format!("languages/{language}/"), file)) - { - let _ = crate::language_creator::create_languages(); + let frontend_messages = file_util::load_file(&path, "frontend.json"); + let frontend_messages = parse(&frontend_messages).unwrap(); + for (key, value) in frontend_messages.entries() { + self.language + .insert(key.to_string(), value.as_str().unwrap().to_string()); } - let loaded = files - .iter() - .all(|file| self.load_file(&format!("languages/{language}/"), file)); - - // A process may have been interrupted while an older version was - // writing a language file. Regenerate the default pack once in that - // case, and still leave custom language packs non-fatal. - if !loaded && language == "en_INT" { - self.language.clear(); - let _ = crate::language_creator::create_languages(); - for file in files { - let _ = self.load_file(&format!("languages/{language}/"), file); - } + let omikron_messages = file_util::load_file(&path, "omikron.json"); + let omikron_messages = parse(&omikron_messages).unwrap(); + for (key, value) in omikron_messages.entries() { + self.language + .insert(key.to_string(), value.as_str().unwrap().to_string()); } - } - fn load_file(&mut self, path: &str, file: &str) -> bool { - let contents = file_util::load_file(path, file); - let Ok(messages) = parse(&contents) else { - return false; - }; + let button_texts = file_util::load_file(&path, "buttons.json"); + let button_texts = parse(&button_texts).unwrap(); + for (key, value) in button_texts.entries() { + self.language + .insert(key.to_string(), value.as_str().unwrap().to_string()); + } - for (key, value) in messages.entries() { - if let Some(value) = value.as_str() { - self.language.insert(key.to_string(), value.to_string()); - } + let debug_messages = file_util::load_file(&path, "debug.json"); + let debug_messages = parse(&debug_messages).unwrap(); + for (key, value) in debug_messages.entries() { + self.language + .insert(key.to_string(), value.as_str().unwrap().to_string()); + } + + let general_messages = file_util::load_file(&path, "general.json"); + let general_messages = parse(&general_messages).unwrap(); + for (key, value) in general_messages.entries() { + self.language + .insert(key.to_string(), value.as_str().unwrap().to_string()); } - true } pub fn get_translation(&self, key: &str) -> String { match self.language.get(key) { diff --git a/src/langu/mod.rs b/src/langu/mod.rs new file mode 100644 index 0000000..352d639 --- /dev/null +++ b/src/langu/mod.rs @@ -0,0 +1,2 @@ +pub mod language_creator; +pub mod language_manager; diff --git a/iota-core/src/main.rs b/src/main.rs similarity index 52% rename from iota-core/src/main.rs rename to src/main.rs index 22a942f..5df39b6 100644 --- a/iota-core/src/main.rs +++ b/src/main.rs @@ -1,62 +1,55 @@ -mod consent_state; -use iota_updater::check_update; +use dashmap::DashSet; +use once_cell::sync::Lazy; use pnet::datalink::NetworkInterface; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::Mutex; +use tokio::sync::RwLock; use tokio::time::{Duration, sleep}; -use iota_state::{AppState, DaemonState}; +mod auth; +mod gui; +mod langu; +mod omikron; +mod terms; +mod users; +mod util; -use iota_cli::screens::main_screen::MainScreen; -use iota_cli::{ipc_client::IpcClient, ui::start_tui}; -use iota_logger::{self as logger, language_creator}; -use iota_logger::{log, log_t}; -use iota_storage::users::user_manager; -use iota_storage::util::config_util::CONFIG; -use iota_util::file_util::{download_and_extract_zip, has_dir}; -use std::sync::Arc; +use crate::gui::app_state; +use crate::gui::app_state::AppState; +use crate::gui::screens::main_screen::MainScreen; +use crate::gui::ui::start_tui; +use crate::langu::language_creator; +use crate::omikron::omikron_connection::OmikronConnection; +use crate::terms::consent_state; +use crate::users::user_manager; +use crate::util::config_util::CONFIG; +use crate::util::file_util::download_and_extract_zip; +use crate::util::file_util::has_dir; +use crate::util::logger; + +pub static APP_STATE: LazyLock>> = + LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); + +pub static SHUTDOWN: Lazy> = Lazy::new(|| RwLock::new(false)); +pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); +pub static ACTIVE_TASKS: Lazy> = Lazy::new(|| DashSet::new()); #[tokio::main(flavor = "multi_thread", worker_threads = 16)] -#[allow(unused_must_use, dead_code, unused_assignments)] +#[allow(unused_must_use, dead_code)] async fn main() { - let state = Arc::new(DaemonState::new()); + while *RELOAD.read().await { + *RELOAD.write().await = false; + *SHUTDOWN.write().await = false; - while *state.reload.read().await { - *state.reload.write().await = false; - *state.shutdown.write().await = false; + let ui = start_tui(); - let socket = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .expect("resolve Iota user paths") - .ipc_endpoint - { - iota_paths::IpcEndpoint::UnixSocket(path) => path, - iota_paths::IpcEndpoint::WindowsPipe(_) => { - panic!("Windows IPC client transport is not implemented yet") - } - }; - let ipc = IpcClient::connect(socket) - .await - .expect("iota-daemon must be running before starting iota-core"); - let session = start_tui(ipc).expect("interactive terminal initialization failed"); - let ui = session.ui(); - - let (eula, tos_pp) = match consent_state::check(ui.clone()).await { - Ok(v) => v, - Err(e) => { - *state.shutdown.write().await = true; - loop { - if state.active_tasks.is_empty() { - break; - } - sleep(Duration::from_millis(100)).await; - } - println!("{}", e); - return; - } - }; + let (eula, tos_pp) = consent_state::check(ui.clone()).await; if !eula { - *state.shutdown.write().await = true; + *SHUTDOWN.write().await = true; loop { - if state.active_tasks.is_empty() { + if ACTIVE_TASKS.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -66,9 +59,9 @@ async fn main() { return; } if !tos_pp { - *state.shutdown.write().await = true; + *SHUTDOWN.write().await = true; loop { - if state.active_tasks.is_empty() { + if ACTIVE_TASKS.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -80,8 +73,7 @@ async fn main() { println!("You can find this at 'agreements'!"); return; } - check_update(); - iota_state::setup(&state); + app_state::setup(); let main_screen = MainScreen::new(ui.clone()).await; ui.set_screen(Box::new(main_screen)).await; @@ -96,15 +88,12 @@ async fn main() { logger::startup(); // BASIC CONFIGURATION - iota_storage::util::config_util::load_config(); + &CONFIG.write().await.load(); // USER MANAGEMENT - if let Err(_) = user_manager::load_users_sync() { + if let Err(_) = user_manager::load_users().await { log_t!("user_load_failed"); } - if let Err(e) = iota_storage::util::settings::migrate_legacy_files() { - log!("Failed to migrate legacy settings: {}", e); - } let mut sb = "".to_string(); @@ -119,11 +108,7 @@ async fn main() { } log!( "IOTA ID: {}", - CONFIG - .load() - .iota_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "N/A".to_string()) + CONFIG.read().await.get_iota_id().to_string() ); log!("User IDS: {}", sb); @@ -142,18 +127,29 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ - let port = CONFIG.load().port; - let mut _ip = "0.0.0.0".to_string(); + let port = CONFIG.read().await.get_port(); + let mut ip = "0.0.0.0".to_string(); for iface in pnet::datalink::interfaces() { let iface: NetworkInterface = iface; if iface.ips.len() > 0 { let ipsv = format!("{}", iface.ips[0]); let ips: &str = ipsv.split('/').next().unwrap_or(""); if format!("{}", ips).starts_with("10.") || format!("{}", ips).starts_with("192.") { - _ip = ips.to_string(); + ip = ips.to_string(); } } } + /* Community port activation is used for activating the port for communities. + * Code is currently commented because communities have not been implemented yet. + if start(port).await { + log_t!("community_active", ip, port.to_string()); + } else { + if port < 1024 { + log_t!("community_start_error_admin", port.to_string()); + } else { + log_t!("community_start_error", port.to_string()); + } + } */ if !has_dir("web") { download_and_extract_zip( "https://omega.tensamin.net/api/download/iota_frontend", @@ -161,30 +157,30 @@ async fn main() { ) .await; } - if !web_server::start(port).await { - log!("Failed to start the MTP web server on port {}", port); - } + let _ = omikron::omikron_connection::get_omikron_connection().await; + log_t!("setup_completed"); loop { - if *state.shutdown.read().await { + if *SHUTDOWN.read().await { break; } - sleep(Duration::from_millis(500)).await; + sleep(Duration::from_millis(100)).await; } - if *state.reload.read().await { + if *RELOAD.read().await { loop { - if state.active_tasks.is_empty() { + if ACTIVE_TASKS.is_empty() { break; } sleep(Duration::from_secs(1)).await; } - iota_storage::util::config_util::clear_config(); + &CONFIG.write().await.clear(); user_manager::clear(); // Commhnities have not been implemented yet. /*community_manager::clear();*/ - *state.app.lock().unwrap() = AppState::new(); + *APP_STATE.lock().unwrap() = AppState::new(); } - let _ = session.shutdown().await; + ui.terminal.lock().unwrap().clear(); + ui.terminal.lock().unwrap().flush(); } } diff --git a/src/omikron/mod.rs b/src/omikron/mod.rs new file mode 100644 index 0000000..acb0f59 --- /dev/null +++ b/src/omikron/mod.rs @@ -0,0 +1,2 @@ +pub mod omikron_connection; +pub mod ping_pong_task; diff --git a/src/omikron/omikron_connection.rs b/src/omikron/omikron_connection.rs new file mode 100755 index 0000000..240da6f --- /dev/null +++ b/src/omikron/omikron_connection.rs @@ -0,0 +1,1172 @@ +use crate::users::contact::Contact; +use crate::util::chat_files::{MessageState, change_message_state}; +use crate::util::chats_util::{get_user, mod_user}; +use crate::util::communities_util::CommunitiesUtil; +use crate::util::crypto_util::{DataFormat, SecurePayload}; +use crate::util::file_util::{get_children, load_file, save_file}; +use crate::util::{chat_files, chats_util}; +use crate::util::{config_util::CONFIG, crypto_helper}; +use crate::{ACTIVE_TASKS, SHUTDOWN, log, log_cv_in, log_cv_out, log_t}; +use dashmap::DashMap; +use json::JsonValue; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio::time::sleep; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; +use ttp_native::{Receiver, Sender}; +use uuid::Uuid; + +// ============================================================================ +// Configuration +// ============================================================================ + +const OMIKRON_HOST_DEFAULT: &str = "methanium.net"; +const OMIKRON_PORT_DEFAULT: u16 = 959; +const RECONNECT_DELAY: Duration = Duration::from_secs(5); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); +const TASK_MAX_AGE: Duration = Duration::from_secs(60); + +// ============================================================================ +// Waiting Task System +// ============================================================================ + +pub struct WaitingTask { + pub task: Box, CommunicationValue) -> bool + Send + Sync>, + pub inserted_at: Instant, +} + +pub static WAITING_TASKS: LazyLock> = LazyLock::new(|| DashMap::new()); + +pub fn start_task_cleanup_loop() { + tokio::spawn(async { + loop { + sleep(TASK_CLEANUP_INTERVAL).await; + WAITING_TASKS.retain(|_, v| v.inserted_at.elapsed() < TASK_MAX_AGE); + } + }); +} + +// ============================================================================ +// Connection State +// ============================================================================ + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected { identified: bool }, +} + +impl ConnectionState { + pub fn is_connected(&self) -> bool { + matches!(self, ConnectionState::Connected { .. }) + } + + pub fn is_identified(&self) -> bool { + matches!(self, ConnectionState::Connected { identified: true }) + } +} + +// ============================================================================ +// Omikron Connection (Client-side with auto-reconnect) +// ============================================================================ + +pub struct OmikronConnection { + state: Arc>, + sender: Arc>>>, + connection_loop_handle: Arc>>>, + host: String, + port: u16, + pub last_ping: Arc>, + heartbeat_handle: Arc>>>, + message_send_times: Arc>>, + pub connection_id: Uuid, + shutdown_tx: Arc>>>, + reconnect_on_close: Arc>, +} + +impl OmikronConnection { + pub fn new() -> Self { + Self::with_host(OMIKRON_HOST_DEFAULT, OMIKRON_PORT_DEFAULT) + } + + pub fn with_host(host: &str, port: u16) -> Self { + let (shutdown_tx, _) = watch::channel(false); + + OmikronConnection { + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + sender: Arc::new(RwLock::new(None)), + connection_loop_handle: Arc::new(Mutex::new(None)), + host: host.to_string(), + port, + last_ping: Arc::new(Mutex::new(-1)), + heartbeat_handle: Arc::new(Mutex::new(None)), + message_send_times: Arc::new(Mutex::new(HashMap::new())), + connection_id: Uuid::new_v4(), + shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), + reconnect_on_close: Arc::new(RwLock::new(true)), + } + } + + // ------------------------------------------------------------------------- + // Connection Management + // ------------------------------------------------------------------------- + + pub async fn connect(self: &Arc) { + if self.connection_loop_handle.lock().await.is_none() { + self.clone().start().await; + } + } + + pub async fn start(self: Arc) { + if let Some(handle) = self.connection_loop_handle.lock().await.take() { + handle.abort(); + } + + *self.reconnect_on_close.write().await = true; + + let self_clone = self.clone(); + let handle = tokio::spawn(async move { + self_clone.connection_loop().await; + }); + + *self.connection_loop_handle.lock().await = Some(handle); + } + + pub async fn stop(&self) { + *self.reconnect_on_close.write().await = false; + + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + + if let Some(handle) = self.connection_loop_handle.lock().await.take() { + handle.abort(); + } + + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + handle.abort(); + } + + if let Some(sender) = self.sender.read().await.as_ref() { + sender.close(); + } + + *self.state.write().await = ConnectionState::Disconnected; + *self.sender.write().await = None; + } + + async fn connection_loop(self: Arc) { + let mut reconnect_delay = RECONNECT_DELAY; + let shutdown_rx = self.shutdown_tx.lock().await.as_ref().unwrap().subscribe(); + let mut shutdown_rx = shutdown_rx; + + loop { + if *shutdown_rx.borrow() || *SHUTDOWN.read().await { + log_t!("omikron_connection_loop_shutdown"); + break; + } + + if !*self.reconnect_on_close.read().await { + break; + } + + match self.clone().connect_once().await { + Ok(()) => { + if *self.reconnect_on_close.read().await { + log!("Connection lost, reconnecting in {:?}...", reconnect_delay); + } else { + break; + } + } + Err(e) => { + log!( + "Connection failed: {}, retrying in {:?}...", + e, + reconnect_delay + ); + } + } + + tokio::select! { + _ = sleep(reconnect_delay) => {} + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + + reconnect_delay = std::cmp::min(reconnect_delay * 2, MAX_RECONNECT_DELAY); + } + } + + async fn connect_once(self: Arc) -> Result<(), String> { + *self.state.write().await = ConnectionState::Connecting; + log_t!("omikron_connecting"); + + let addr_str = format!("https://{}:{}/ws/iota/", self.host, self.port); + + let (sender, mut receiver) = ttp_native::client::connect(&addr_str, None) + .await + .map_err(|e| format!("Connection failed: {}", e))?; + + log_t!("omikron_connection_success"); + + let sender_arc = Arc::new(sender); + *self.sender.write().await = Some(sender_arc.clone()); + *self.state.write().await = ConnectionState::Connected { identified: false }; + + // Handle registration/identification + self.handle_authentication().await; + + // Start read loop + let read_self = self.clone(); + let read_handle = tokio::spawn(async move { + read_self.read_loop(&mut receiver).await; + }); + + // Start heartbeat + let heartbeat_self = self.clone(); + let heartbeat_handle = tokio::spawn(async move { + heartbeat_self.heartbeat_loop().await; + }); + *self.heartbeat_handle.lock().await = Some(heartbeat_handle); + + { + ACTIVE_TASKS.insert("Omikron Listener".to_string()); + } + + // Wait for read loop to complete + let result = read_handle.await; + *self.sender.write().await = None; + *self.state.write().await = ConnectionState::Disconnected; + { + ACTIVE_TASKS.remove("Omikron Listener"); + } + + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + handle.abort(); + } + + match result { + Ok(()) => { + if *self.reconnect_on_close.read().await { + Err("Connection closed, will reconnect".to_string()) + } else { + Ok(()) + } + } + Err(e) => Err(format!("Read loop error: {}", e)), + } + } + + // ------------------------------------------------------------------------- + // Authentication (Registration/Identification) + // ------------------------------------------------------------------------- + + async fn handle_authentication(&self) { + let conf = CONFIG.read().await; + let iota_id = conf.get_iota_id(); + let public_key = conf.get_public_key(); + let private_key = conf.get_private_key(); + drop(conf); + + if iota_id == 0 || public_key.is_none() || private_key.is_none() { + log_t!("iota_register_new"); + + let key_pair = crypto_helper::generate_keypair(); + let public_key_base64 = crypto_helper::public_key_to_base64(&key_pair.public); + let _private_key_base64 = crypto_helper::secret_key_to_base64(&key_pair.secret); + + let mut conf_write = CONFIG.write().await; + // NOTE: + // Intentionally not storing the generated private/public keys directly into the + // config file here to avoid persisting sensitive material in plaintext. If you + // want to persist them, uncomment the two lines below and accept the security + // implications (they will be saved by `conf_write.update()`). + // conf_write.change("public_key", DataValue::Str(public_key_base64.clone())); + // conf_write.change("private_key", DataValue::Str(private_key_base64)); + conf_write.update(); + drop(conf_write); + + let register_msg = CommunicationValue::new(CommunicationType::register_iota) + .add_data(DataTypes::public_key, DataValue::Str(public_key_base64)); + + let msg_id = register_msg.get_id(); + + WAITING_TASKS.insert( + msg_id, + WaitingTask { + task: Box::new(|selfc, cv| { + if !cv.is_type(CommunicationType::success) { + return false; + } + + let iota_value = cv.get_data(DataTypes::register_id); + let iota_id = iota_value.as_number().unwrap_or(0); + + if iota_id != 0 { + tokio::spawn(async move { + let mut conf_write = CONFIG.write().await; + conf_write.change("iota_id", JsonValue::from(iota_id)); + conf_write.update(); + drop(conf_write); + log!("Registered with Iota-ID: {}", iota_id); + + // Send identification after registration + let identify_msg = + CommunicationValue::new(CommunicationType::identification) + .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + selfc.send_message(&identify_msg).await; + }); + } else { + log!("Iota registration failed."); + } + true + }), + inserted_at: Instant::now(), + }, + ); + + self.send_message(®ister_msg).await; + } else { + let identify_msg = CommunicationValue::new(CommunicationType::identification) + .add_data(DataTypes::iota_id, DataValue::Number(iota_id)); + self.send_message(&identify_msg).await; + } + } + + // ------------------------------------------------------------------------- + // Read Loop & Heartbeat + // ------------------------------------------------------------------------- + + async fn read_loop(self: Arc, receiver: &mut Receiver) { + loop { + let result = receiver.receive().await; + match result { + Ok(cv) => { + self.clone().handle_message(cv).await; + } + Err(e) => { + self.fail_all_waiting_tasks(format!( + "Connection receive error: {} (connection_id={})", + e, self.connection_id + )) + .await; + break; + } + } + if !receiver.is_open() { + self.fail_all_waiting_tasks(format!( + "Connection closed (connection_id={}, receiver_open=false)", + self.connection_id + )) + .await; + break; + } + } + } + + async fn heartbeat_loop(self: Arc) { + loop { + sleep(HEARTBEAT_INTERVAL).await; + + if !self.state.read().await.is_connected() { + break; + } + + if let Some(sender) = self.sender.read().await.as_ref() { + if !sender.is_open() { + break; + } + } else { + break; + } + + self.send_ping().await; + } + } + + // ------------------------------------------------------------------------- + // Message Handling (Preserved from original) + // ------------------------------------------------------------------------- + + pub async fn handle_message(self: Arc, cv: CommunicationValue) { + if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + log_cv_in!(&cv); + } + + let msg_id = cv.get_id(); + + // Dispatch waiting task for this message id + if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { + if (task.task)(self.clone(), cv.clone()) { + return; + } + } + + if cv.is_type(CommunicationType::pong) { + self.handle_pong(&cv).await; + return; + } + + if cv.is_type(CommunicationType::challenge) { + self.handle_challenge(&cv).await; + return; + } + + if cv.is_type(CommunicationType::identification_response) { + if let Some(_accepted) = cv.get_data(DataTypes::accepted).as_bool() { + let mut state = self.state.write().await; + if let ConnectionState::Connected { identified: _ } = *state { + *state = ConnectionState::Connected { identified: true }; + } + } + return; + } + + // ************************************************ // + // Direct messages // + // ************************************************ // + + if cv.is_type(CommunicationType::message_state) { + let sender_id = &cv.get_sender(); + let receiver_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + Some(id) => id, + _ => return, + }; + + // Parse send_time robustly: accept numeric or string, fallback to current time + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp_i64 = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + *sender_id as i64, + MessageState::from_str( + cv.get_data(DataTypes::message_state).as_str().unwrap_or(""), + ), + ); + } + + // Incoming storsed message: store for the recipient, attempt local delivery, notify sender. + if cv.is_type(CommunicationType::message_send) { + let sender_id: u64 = cv.get_sender(); + + // parse receiver_id (the storage owner for this incoming message) + let receiver_id: i64 = if let Some(n) = cv.get_data(DataTypes::receiver_id).as_number() + { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::receiver_id).as_str() { + s.parse::().unwrap_or(0) + } else { + 0 + }; + + // parse send_time robustly (number or string), fallback to now + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp_i64 = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + let timestamp_u128 = timestamp_i64 as u128; + + // content may be missing; default to empty string + let content = cv + .get_data(DataTypes::content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + + // persist message for the receiver (storage_owner = receiver_id) + chat_files::add_message( + timestamp_u128, + false, + receiver_id as i64, + sender_id as i64, + &content, + height, + ); + + // persist message for the sender (storage_owner = sender_id) + chat_files::add_message( + timestamp_u128, + true, + sender_id as i64, + receiver_id as i64, + &content, + height, + ); + + // send confirmation back to sender + let conf_msg = CommunicationValue::new(CommunicationType::message_send) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64); + self.send_message(&conf_msg).await; + + // Build a live-delivery message for the local client (recipient) + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data(DataTypes::content, DataValue::Str(content.clone())) + .add_data(DataTypes::sender_id, DataValue::Number(sender_id as i64)) + .add_data(DataTypes::height, DataValue::Number(height)); + + // Attempt delivery and await a response from the local client + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; + + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + // update stored message state for receiver + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + ms.clone(), + ); + + // update stored message state for sender + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + ms.clone(), + ); + + // notify original sender about the delivered/read state + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(sender_id as i64), + ) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery failed or timed out; mark as Sent + let _ = chat_files::change_message_state( + timestamp_i64, + receiver_id as i64, + sender_id as i64, + MessageState::Sent, + ); + + let _ = chat_files::change_message_state( + timestamp_i64, + sender_id as i64, + receiver_id as i64, + MessageState::Sent, + ); + + // notify sender + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(sender_id as u64) + .with_sender(receiver_id as u64) + .add_data(DataTypes::send_time, DataValue::Number(timestamp_i64)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } + + if cv.is_type(CommunicationType::message_other_iota) { + let sender_id = &cv.get_sender(); + let receiver_id = &cv.get_receiver(); + + // parse send_time safely (number or string), fallback to now + let send_time_val = cv.get_data(DataTypes::send_time); + let now_i64 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let timestamp = if let Some(n) = send_time_val.as_number() { + n as i64 + } else if let Some(s) = send_time_val.as_str() { + s.parse::().unwrap_or(now_i64) + } else { + now_i64 + }; + + // content may be missing or non-string; default to empty string + let content = cv + .get_data(DataTypes::content) + .as_str() + .unwrap_or("") + .to_string(); + + let height = cv.get_data(DataTypes::height).as_number().unwrap_or(0) as i64; + + chat_files::add_message( + timestamp as u128, + false, + *receiver_id as i64, + *sender_id as i64, + &content, + height, + ); + + // Build user_forward using the parsed numeric timestamp and safe content string + let user_forward = CommunicationValue::new(CommunicationType::message_live) + .with_id(cv.get_id()) + .with_receiver(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data(DataTypes::content, DataValue::Str(content.clone())) + .add_data(DataTypes::sender_id, DataValue::Number(*sender_id as i64)) + .add_data(DataTypes::height, DataValue::Number(height)); + + let user_resp = self + .clone() + .await_response(&user_forward, Some(Duration::from_secs(10))) + .await; + + if let Ok(user_resp) = user_resp { + let ms_raw = user_resp + .get_data(DataTypes::message_state) + .as_string() + .unwrap_or_else(|| "".to_string()); + let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); + + let _ = change_message_state( + timestamp, + *receiver_id as i64, + *sender_id as i64, + ms.clone(), + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(*sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(ms.as_str().to_string()), + ), + ) + .await; + } else { + // Delivery timed out/failed — update stored state and notify sender with numeric timestamp + let _ = chat_files::change_message_state( + timestamp, + *receiver_id as i64, + *sender_id as i64, + MessageState::Sent, + ); + + self.send_message( + &CommunicationValue::new(CommunicationType::message_state) + .with_id(cv.get_id()) + .with_receiver(*sender_id) + .with_sender(*receiver_id) + .add_data(DataTypes::send_time, DataValue::Number(timestamp)) + .add_data( + DataTypes::chat_partner_id, + DataValue::Number(*sender_id as i64), + ) + .add_data( + DataTypes::message_state, + DataValue::Str(MessageState::Sent.as_str().to_string()), + ), + ) + .await; + } + return; + } + + if cv.is_type(CommunicationType::messages_get) { + let my_id = cv.get_sender(); + let partner_id = cv.get_data(DataTypes::user_id).as_number().unwrap_or(0); + let offset = cv.get_data(DataTypes::offset).as_number().unwrap_or(0); + let amount = cv.get_data(DataTypes::amount).as_number().unwrap_or(0); + let messages = chat_files::get_messages(my_id as i64, partner_id, offset, amount); + let mut msg_array: Vec = Vec::new(); + for m in messages.members() { + let message_time: i64 = m["message_time"].as_i64().unwrap_or(0); + let content: String = m["content"].as_str().unwrap_or("").to_string(); + let sent_by_self: bool = m["sent_by_self"].as_bool().unwrap_or(false); + let height: i64 = m["height"].as_i64().unwrap_or(0); + let sender_id: i64 = if sent_by_self { + my_id as i64 + } else { + if let Some(n) = cv.get_data(DataTypes::chat_partner_id).as_number() { + n as i64 + } else if let Some(s) = cv.get_data(DataTypes::chat_partner_id).as_str() { + s.parse::().unwrap_or(partner_id as i64) + } else { + partner_id as i64 + } + }; + let message_state: String = m["message_state"].as_str().unwrap_or("").to_string(); + + let mut container = Vec::new(); + container.push((DataTypes::send_time, DataValue::Number(message_time))); + container.push((DataTypes::content, DataValue::Str(content))); + container.push((DataTypes::sender_id, DataValue::Number(sender_id))); + container.push((DataTypes::message_state, DataValue::Str(message_state))); + container.push((DataTypes::height, DataValue::Number(height))); + container.push((DataTypes::sent_by_self, DataValue::Bool(sent_by_self))); + msg_array.push(DataValue::Container(container)); + } + + let resp = CommunicationValue::new(CommunicationType::messages_get) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::messages, DataValue::Array(msg_array)); + + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::get_chats) { + let user_id = cv.get_sender(); + let users = chats_util::get_users(user_id as i64); + let mut user_array = Vec::new(); + for user in users { + let mut container = Vec::new(); + container.push((DataTypes::user_id, DataValue::Number(user.user_id))); + if let Some(name) = user.user_name { + container.push((DataTypes::username, DataValue::Str(name))); + } + if let Some(ts) = user.last_message_at { + container.push((DataTypes::last_message_at, DataValue::Number(ts))); + } + user_array.push(DataValue::Container(container)); + } + let resp = CommunicationValue::new(CommunicationType::get_chats) + .with_id(cv.get_id()) + .with_receiver(user_id) + .add_data(DataTypes::user_ids, DataValue::Array(user_array)); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::add_conversation) { + let user_id = cv.get_sender(); + let other_id = match cv.get_data(DataTypes::chat_partner_id).as_number() { + Some(n) => n as i64, + None => cv + .get_data(DataTypes::chat_partner_id) + .as_str() + .unwrap_or("0") + .parse() + .unwrap_or(0), + }; + let mut contact = get_user(user_id as i64, other_id).unwrap_or(Contact::new(other_id)); + + if let Some(name) = cv.get_data(DataTypes::chat_partner_name).as_str() { + contact.user_name = Some(name.to_string()); + } + + contact.set_last_message_at( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64, + ); + mod_user(user_id as i64, &contact); + let resp = CommunicationValue::new(CommunicationType::add_conversation) + .with_id(cv.get_id()) + .with_receiver(user_id); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::add_community) { + CommunitiesUtil::add_community( + cv.get_sender() as i64, + cv.get_data(DataTypes::community_address) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataTypes::community_title) + .as_str() + .unwrap() + .to_string(), + cv.get_data(DataTypes::position) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::add_community) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::get_communities) { + let mut comm_array = Vec::new(); + for c in CommunitiesUtil::get_communities(cv.get_sender() as i64) { + let mut container: Vec<(DataTypes, DataValue)> = Vec::new(); + if let Some(address) = c["address"].as_str() { + container.push(( + DataTypes::community_address, + DataValue::Str(address.to_string()), + )); + } + if let Some(title) = c["title"].as_str() { + container.push(( + DataTypes::community_title, + DataValue::Str(title.to_string()), + )); + } + if let Some(position) = c["position"].as_str() { + container.push((DataTypes::position, DataValue::Str(position.to_string()))); + } + comm_array.push(DataValue::Container(container)); + } + + let resp = CommunicationValue::new(CommunicationType::get_communities) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()) + .add_data(DataTypes::communities, DataValue::Array(comm_array)); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::remove_community) { + CommunitiesUtil::remove_community( + cv.get_sender() as i64, + cv.get_data(DataTypes::community_address) + .as_str() + .unwrap() + .to_string(), + ); + let resp = CommunicationValue::new(CommunicationType::remove_community) + .with_id(cv.get_id()) + .with_receiver(cv.get_sender()); + self.send_message(&resp).await; + return; + } + + if cv.is_type(CommunicationType::settings_save) { + let my_id = cv.get_sender(); + let settings_name = cv.get_data(DataTypes::settings_name).as_str().unwrap(); + let settings_value = cv.get_data(DataTypes::payload).as_str().unwrap(); + + save_file( + &format!("users/{}/settings/", my_id), + &format!("{}.settings", settings_name), + &settings_value, + ); + + let response = CommunicationValue::new(CommunicationType::settings_save) + .with_receiver(my_id) + .with_id(cv.get_id()); + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::settings_load) { + let my_id = cv.get_sender(); + let settings_name = cv.get_data(DataTypes::settings_name).as_string().unwrap(); + let settings_value_str = load_file( + &format!("users/{}/settings/", my_id), + &format!("{}.settings", settings_name), + ); + let response = CommunicationValue::new(CommunicationType::settings_load) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::payload, DataValue::Str(settings_value_str)) + .add_data(DataTypes::settings_name, DataValue::Str(settings_name)); + + self.send_message(&response).await; + return; + } + + if cv.is_type(CommunicationType::settings_list) { + let my_id = cv.get_sender(); + let settings = get_children(&format!("users/{}/settings/", my_id)); + let mut settings_json = Vec::new(); + for s in settings { + let s = s.replace(".settings", ""); + if s.is_empty() { + continue; + } + let _ = settings_json.push(DataValue::Str(s)); + } + let response = CommunicationValue::new(CommunicationType::settings_list) + .with_id(cv.get_id()) + .with_receiver(my_id) + .add_data(DataTypes::settings, DataValue::Array(settings_json)); + + self.send_message(&response).await; + return; + } + } + + async fn handle_challenge(&self, cv: &CommunicationValue) { + let conf = CONFIG.read().await; + let private_key = conf.get_private_key().unwrap(); + drop(conf); + + let omikron_public_key = cv.get_data(DataTypes::public_key).as_str().unwrap(); + let encrypted_challenge = cv.get_data(DataTypes::challenge).as_str().unwrap(); + + let solved_challenge = { + if let Ok(decrypted) = SecurePayload::new( + encrypted_challenge, + DataFormat::Base64, + crypto_helper::load_secret_key(&private_key).unwrap(), + ) { + if let Ok(decrypted) = decrypted + .decrypt_x448(crypto_helper::load_public_key(omikron_public_key).unwrap()) + { + Some(decrypted) + } else { + None + } + } else { + None + } + }; + + if let Some(decrypted) = solved_challenge { + let solved = decrypted.export(DataFormat::Raw); + + let response = CommunicationValue::new(CommunicationType::challenge_response) + .with_id(cv.get_id()) + .add_data(DataTypes::challenge, DataValue::Str(solved)); + + self.send_message(&response).await; + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + pub async fn send_message(&self, cv: &CommunicationValue) { + if let Err(err) = self.send_message_result(cv).await { + log_t!("send_message_failed", err); + } + } + + async fn send_message_result(&self, cv: &CommunicationValue) -> Result<(), String> { + let sender_guard = self.sender.read().await; + if let Some(sender) = sender_guard.as_ref() { + if !sender.is_open() { + drop(sender_guard); + if let Some(sender) = self.sender.write().await.take() { + sender.close(); + } + self.fail_all_waiting_tasks(format!( + "Send failed: connection closed (connection_id={})", + self.connection_id + )) + .await; + return Err("connection closed".to_string()); + } + + let sender_clone = Arc::clone(sender); + drop(sender_guard); + + if !cv.is_type(CommunicationType::ping) && !cv.is_type(CommunicationType::pong) { + log_cv_out!(&cv); + } + + if let Err(e) = sender_clone.send(cv).await { + self.fail_all_waiting_tasks(format!( + "Send failed: {} (connection_id={})", + e, self.connection_id + )) + .await; + return Err(e.to_string()); + } + + Ok(()) + } else { + Err("not connected".to_string()) + } + } + + async fn fail_all_waiting_tasks(&self, reason: String) { + let keys: Vec = WAITING_TASKS.iter().map(|entry| *entry.key()).collect(); + + for key in keys { + if let Some((_, waiting_task)) = WAITING_TASKS.remove(&key) { + let response = CommunicationValue::new(CommunicationType::error) + .with_id(key) + .add_data(DataTypes::message, DataValue::Str(reason.clone())); + let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response); + } + } + } + + pub async fn is_connected(&self) -> bool { + self.state.read().await.is_connected() + } + + pub async fn is_identified(&self) -> bool { + self.state.read().await.is_identified() + } + + pub async fn await_response( + &self, + cv: &CommunicationValue, + timeout_duration: Option, + ) -> Result { + let (tx, mut rx) = mpsc::channel(1); + let msg_id = cv.get_id(); + + WAITING_TASKS.insert( + msg_id, + WaitingTask { + task: Box::new(move |_, response_cv| { + let inner_tx = tx.clone(); + tokio::spawn(async move { + let _ = inner_tx.send(response_cv).await; + }); + true + }), + inserted_at: Instant::now(), + }, + ); + + if let Err(send_err) = self.send_message_result(cv).await { + WAITING_TASKS.remove(&msg_id); + return Err(format!( + "Request send failed (msg_id={}, reason={})", + msg_id, send_err + )); + } + + let timeout = timeout_duration.unwrap_or(Duration::from_secs(10)); + + match tokio::time::timeout(timeout, rx.recv()).await { + Ok(Some(response_cv)) => { + if response_cv.is_type(CommunicationType::error) { + let reason = response_cv + .get_data(DataTypes::message) + .as_str() + .unwrap_or("connection error") + .to_string(); + Err(format!( + "Request failed due to disconnect (msg_id={}, reason={})", + msg_id, reason + )) + } else { + Ok(response_cv) + } + } + Ok(_) => { + WAITING_TASKS.remove(&msg_id); + Err("Channel closed while awaiting response".to_string()) + } + Err(_) => { + let waiting_tasks_len = WAITING_TASKS.len(); + WAITING_TASKS.remove(&msg_id); + Err(format!( + "Request timed out (msg_id={}, timeout={}s, connected={}, waiting_tasks={})", + msg_id, + timeout.as_secs(), + self.is_connected().await, + waiting_tasks_len + )) + } + } + } + + pub async fn await_connection(&self, timeout_duration: Option) -> Result<(), String> { + if self.state.read().await.is_connected() { + return Ok(()); + } + + let timeout = timeout_duration.unwrap_or(CONNECTION_TIMEOUT); + let start = Instant::now(); + + loop { + if self.state.read().await.is_connected() { + return Ok(()); + } + + if start.elapsed() >= timeout { + return Err(format!( + "Connection not established within {} seconds", + timeout.as_secs() + )); + } + + sleep(Duration::from_millis(100)).await; + } + } +} + +// ============================================================================ +// Global Instance +// ============================================================================ + +pub static OMIKRON_CONNECTION: LazyLock> = LazyLock::new(|| { + let conn = Arc::new(OmikronConnection::new()); + + start_task_cleanup_loop(); + + conn +}); + +pub async fn get_omikron_connection() -> Arc { + let conn = OMIKRON_CONNECTION.clone(); + + conn.connect().await; + conn +} diff --git a/src/omikron/ping_pong_task.rs b/src/omikron/ping_pong_task.rs new file mode 100644 index 0000000..f1a891f --- /dev/null +++ b/src/omikron/ping_pong_task.rs @@ -0,0 +1,38 @@ +use crate::omikron::omikron_connection::OmikronConnection; +use crate::{APP_STATE, log}; +use dashmap::DashMap; +use std::sync::LazyLock; +use std::time::Instant; +use tokio::time::Duration; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue, rand_u32}; + +static PING_TIMES: LazyLock> = LazyLock::new(|| DashMap::new()); + +impl OmikronConnection { + pub async fn send_ping(&self) { + let id = rand_u32(); + + PING_TIMES.insert(id, Instant::now()); + + PING_TIMES.retain(|_, v| v.elapsed() < Duration::from_secs(30)); + + let ping_message = CommunicationValue::new(CommunicationType::ping) + .with_id(id) + .add_data( + DataTypes::last_ping, + DataValue::Array(vec![DataValue::Number(*self.last_ping.lock().await)]), + ); + + self.send_message(&ping_message).await; + } + + pub async fn handle_pong(&self, cv: &CommunicationValue) { + let id = cv.get_id(); + + if let Some((_, send_time)) = PING_TIMES.remove(&id) { + let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64; + *self.last_ping.lock().await = ping_ms; + APP_STATE.lock().unwrap().push_ping_val(ping_ms as f64); + } + } +} diff --git a/src/server/api.rs b/src/server/api.rs new file mode 100755 index 0000000..f9a87a4 --- /dev/null +++ b/src/server/api.rs @@ -0,0 +1,190 @@ +use crate::server::server::is_local_network; +use crate::util::config_util::CONFIG; +use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use serde_json::{Value, json}; +use std::net::SocketAddr; +use std::sync::Arc; + +pub fn api_config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api") + .route("/shutdown/", web::post().to(shutdown)) + .route("/reload/", web::post().to(reload)) + .route("/users/add/", web::post().to(users_add)) + .route("/users/remove/", web::post().to(users_remove)) + .route("/users/get/", web::get().to(users_get)) + .route("/communities/add/", web::post().to(communities_add)) + .route("/communities/get/", web::get().to(communities_get)) + .route("/settings/set/", web::post().to(settings_set)) + .route("/settings/get/", web::get().to(settings_get)), + ); +} + +async fn settings_set(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let key = req.headers().get("key").and_then(|v| v.to_str().ok()); + let value = req.headers().get("value").and_then(|v| v.to_str().ok()); + + match (key, value) { + (Some(k), Some(v)) => { + let _ = CONFIG + .write() + .await + .config + .insert(&k.to_string(), v.to_string()); + + success() + } + _ => error(), + } +} + +async fn settings_get(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + let config = CONFIG.read().await.config.clone(); + let serde_config: Value = serde_json::to_value(config.to_string()).unwrap(); + HttpResponse::Ok().json(serde_config) +} + +async fn communities_get(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let communities = crate::communities::community_manager::get_communities().await; + + let mut list = Vec::new(); + + for c in communities { + let val = c.frontend().await; + let s_val: Value = serde_json::to_value(val.to_string()).unwrap(); + list.push(s_val); + } + HttpResponse::Ok().json(list) +} + +async fn communities_add( + req: HttpRequest, + ssl: web::Data, + payload: web::Json, +) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let name = payload["name"].as_str().unwrap_or("").to_string(); + let owner = payload["owner"].as_i64().unwrap_or(0); + + let community = Arc::new(crate::communities::community::Community::create(name, owner).await); + + crate::communities::community_manager::add_community(community).await; + + success() +} + +async fn users_get(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let users = crate::users::user_manager::get_users(); + + let list: Vec<_> = users + .into_iter() + .map(|u| { + let val = u.frontend(); + serde_json::to_value(val.to_string()).unwrap() + }) + .collect(); + + HttpResponse::Ok().json(list) +} + +async fn users_remove( + req: HttpRequest, + ssl: web::Data, + payload: web::Json, +) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let uuid = payload.get("uuid").and_then(|v| v.as_i64()).unwrap_or(0); + + crate::users::user_manager::remove_user(uuid); + crate::users::user_manager::save_users(); + + success() +} + +async fn users_add( + req: HttpRequest, + ssl: web::Data, + payload: web::Json, +) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + let username = match payload.get("username").and_then(|v| v.as_str()) { + Some(u) => u, + _ => return error(), + }; + + if let (Some(user), Some(_)) = crate::users::user_manager::create_user(username).await { + let val = user.frontend(); + let s_val: Value = serde_json::to_value(val.to_string()).unwrap(); + HttpResponse::Ok().json(s_val) + } else { + error() + } +} + +async fn shutdown(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + *crate::SHUTDOWN.write().await = true; + success() +} + +async fn reload(req: HttpRequest, ssl: web::Data) -> impl Responder { + if !is_allowed_req(&req, *ssl.get_ref()) { + return forbidden(); + } + + *crate::SHUTDOWN.write().await = true; + *crate::RELOAD.write().await = true; + + success() +} + +fn forbidden() -> HttpResponse { + HttpResponse::Forbidden().body("403 Forbidden") +} + +fn success() -> HttpResponse { + HttpResponse::Ok().json(json!({ "type": "success" })) +} + +fn error() -> HttpResponse { + HttpResponse::Ok().json(json!({ "type": "error" })) +} + +fn is_allowed(addr: SocketAddr, ssl: bool) -> bool { + is_local_network(addr.ip()) || ssl +} + +fn is_allowed_req(req: &HttpRequest, ssl: bool) -> bool { + if let Some(addr) = req.peer_addr() { + is_allowed(addr, ssl) + } else { + false + } +} diff --git a/web-ui/src/lib.rs b/src/server/mod.rs similarity index 100% rename from web-ui/src/lib.rs rename to src/server/mod.rs diff --git a/web-ui/src/server.rs b/src/server/server.rs similarity index 77% rename from web-ui/src/server.rs rename to src/server/server.rs index f799b23..b55a50f 100644 --- a/web-ui/src/server.rs +++ b/src/server/server.rs @@ -1,9 +1,10 @@ -use crate::api::api_config; -use crate::web_path_parser; -use actix_web::{App, HttpServer, dev::ServerHandle, web}; -use iota_logger::log; -use iota_state::DaemonState; -use iota_util::file_util::load_file_buf; +use crate::log; +use crate::server::api::api_config; +use crate::server::web_path_parser; +use crate::util::file_util::load_file_buf; +use crate::{ACTIVE_TASKS, SHUTDOWN}; +use actix_web::{App, Error, HttpRequest, HttpServer, Responder, dev::ServerHandle, web}; +use actix_web_actors::ws; use rustls::ServerConfig; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::{ @@ -14,43 +15,44 @@ use std::{ time::Duration, }; +async fn ws_handler(req: HttpRequest, stream: web::Payload) -> Result { + let path = req.path().to_string(); + log!("WS connection from {:?}", req.peer_addr()); + let session = WsSession::new(path); + ws::start(session, &req, stream) +} + use tokio::sync::oneshot; -pub async fn start(port: u16, state: Arc) -> bool { +pub async fn start(port: u16) -> bool { let (tx, rx) = oneshot::channel::(); - let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1".to_string()); - let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS"); - - let server_state = state.clone(); let _ = tokio::spawn(async move { let server = match load_tls_config() { Ok(Some(tls_config)) => { - log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port); + log!("HTTPS (HTTP/2) Server running on 0.0.0.0:{}", port); let _config = (*tls_config).clone(); - let app_state = server_state.clone(); HttpServer::new(move || { App::new() .app_data(web::Data::new(true)) - .app_data(web::Data::from(app_state.clone())) .configure(api_config) + .service(web::resource("/ws/{path:.*}").route(web::get().to(ws_handler))) .default_service(web::to(web_path_parser::handle)) }) - .bind((bind_ip, port)) + .bind(("0.0.0.0", port)) .unwrap() .run() } Ok(_) => { - log!("HTTP Server running on {}:{}", bind_addr, port); - let app_state = server_state.clone(); + log!("HTTP Server running on 0.0.0.0:{}", port); HttpServer::new(move || { App::new() .app_data(web::Data::new(false)) - .app_data(web::Data::from(app_state.clone())) .configure(api_config) + .service(web::resource("/ws/{path:.*}").route(web::get().to(ws_handler))) .default_service(web::to(web_path_parser::handle)) }) - .bind((bind_ip, port)) + .bind(("0.0.0.0", port)) .unwrap() .run() } @@ -63,15 +65,15 @@ pub async fn start(port: u16, state: Arc) -> bool { let server_handle = server.handle(); tx.send(server_handle).unwrap(); - server_state.active_tasks.insert("WebServer".into()); + ACTIVE_TASKS.insert("WebServer".into()); server.await.unwrap(); - server_state.active_tasks.remove("WebServer"); + ACTIVE_TASKS.remove("WebServer"); log!("Web Server shutdown complete."); }); if let Ok(server_handle) = rx.await { tokio::spawn(async move { - wait_for_shutdown(server_handle, state).await; + wait_for_shutdown(server_handle).await; }); true } else { @@ -79,9 +81,9 @@ pub async fn start(port: u16, state: Arc) -> bool { } } -async fn wait_for_shutdown(server_handle: ServerHandle, state: Arc) { +async fn wait_for_shutdown(server_handle: ServerHandle) { loop { - if *state.shutdown.read().await { + if *SHUTDOWN.read().await { log!("Shutdown signal received."); server_handle.stop(true).await; break; diff --git a/web-ui/src/web_path_parser.rs b/src/server/web_path_parser.rs similarity index 98% rename from web-ui/src/web_path_parser.rs rename to src/server/web_path_parser.rs index 2eaf3cf..a82c8db 100755 --- a/web-ui/src/web_path_parser.rs +++ b/src/server/web_path_parser.rs @@ -1,7 +1,7 @@ use actix_web::{HttpRequest, HttpResponse}; use std::path::{Path, PathBuf}; -use iota_util::file_util::load_file_vec; +use crate::util::file_util::load_file_vec; fn codec_for_ext(ext: &str) -> &'static str { match ext { diff --git a/src/terms/buttons.rs b/src/terms/buttons.rs new file mode 100644 index 0000000..c545a4c --- /dev/null +++ b/src/terms/buttons.rs @@ -0,0 +1,175 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::terms::focus::Focus; + +#[allow(mismatched_lifetime_syntaxes)] +pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line { + let box_char = if checked { "[x]" } else { "[ ]" }; + let (box_style, text_style) = if active { + if allowed { + ( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + ( + Style::default().fg(Color::Gray), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } + } else { + (Style::default(), Style::default()) + }; + Line::from(vec![ + Span::styled(box_char, box_style), + Span::raw(" "), + Span::styled(label, text_style), + ]) +} + +pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) { + let p = Paragraph::new(Span::styled(label, style)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(p, area); +} +pub fn draw_buttons( + f: &mut ratatui::Frame, + area: Rect, + current_focus: Focus, + state: (bool, bool), + update_needed: bool, + downgrade_scenario: bool, + tos_or_privacy: bool, +) { + let cancel_text = if update_needed { + "[Q] Quit" + } else { + "[Q] Not now" + }; + let continue_text = if downgrade_scenario { + "Downgrade" + } else { + "Continue" + }; + let mut buttons = vec![ + (cancel_text, Focus::Cancel), + (continue_text, Focus::Continue), + ]; + if tos_or_privacy { + buttons.push(("Continue with Tensamin Services", Focus::ContinueAll)); + } + + let padding = 2; + let min_widths: Vec = buttons + .iter() + .map(|(label, _)| label.len() as u16 + padding) + .collect(); + + let widths = compute_widths(area.width, &min_widths); + + let mut x = area.x; + + for ((label, focus), width) in buttons.iter().zip(widths) { + let chunk = Rect { + x, + y: area.y, + width, + height: area.height, + }; + x += width; + + let is_focused = current_focus == *focus; + + let style = match focus { + Focus::Cancel => { + if is_focused { + Style::default() + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red) + } + } + + Focus::Continue => { + if is_focused && state.0 { + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD) + } else if state.0 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + } + } + + Focus::ContinueAll => { + if is_focused && state.1 { + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD) + } else if state.1 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + } + } + + _ => Style::default().fg(Color::DarkGray), + }; + + draw_button(f, chunk, label, style); + } +} +pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec { + let mut widths = vec![0; min_widths.len()]; + let mut remaining: Vec = (0..min_widths.len()).collect(); + + let mut remaining_width = area_width; + + while !remaining.is_empty() { + let count = remaining.len() as u16; + let equal = remaining_width / count; + + let mut clamped = Vec::new(); + + for &i in &remaining { + if min_widths[i] > equal { + widths[i] = min_widths[i]; + remaining_width -= min_widths[i]; + clamped.push(i); + } + } + + if clamped.is_empty() { + let mut remainder = remaining_width % count; + for &i in &remaining { + widths[i] = equal + + if remainder > 0 { + remainder -= 1; + 1 + } else { + 0 + }; + } + break; + } + + remaining.retain(|i| !clamped.contains(i)); + } + + widths +} diff --git a/iota-core/src/consent_state.rs b/src/terms/consent_state.rs similarity index 87% rename from iota-core/src/consent_state.rs rename to src/terms/consent_state.rs index 556ebe5..1cc6e0a 100644 --- a/iota-core/src/consent_state.rs +++ b/src/terms/consent_state.rs @@ -1,79 +1,71 @@ +use tokio::sync::oneshot; + +use crate::{ + gui::{ + screens::{terms_checker::TermsCheckerScreen, terms_updater::TermsUpdaterScreen}, + ui::UI, + }, + terms::{ + doc::Doc, + terms_getter::{Type, get_current_docs, get_newest_docs}, + }, + util::file_util::{load_file, save_file}, +}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; -use iota_cli::ui::UI; -use iota_terms::{Doc, TermsType as Type, get_current_docs}; -use iota_util::file_util::{load_file, save_file}; -use tokio::sync::oneshot; - -pub async fn check(ui: Arc) -> Result<(bool, bool), String> { +pub async fn check(ui: Arc) -> (bool, bool) { let mut state = ConsentState::load_state(); - ensure_initial_consent(ui.clone(), &mut state).await?; - /* - * The raw legal endpoint exposes only the current document. Restore this - * flow when it provides future versions that users can accept early. - */ - // ensure_updates(ui, &mut state).await?; + if ensure_initial_consent(ui.clone(), &mut state) + .await + .is_err() + { + return (false, false); + } + if ensure_updates(ui, &mut state).await.is_err() { + return (false, false); + }; state = state.sanitize(); state.save_state(); - Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) + (state.accepted_eula, state.accepted_tos && state.accepted_pp) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NonInteractiveConsent { - Accepted, - RequiresInteractiveAcceptance, -} - -pub fn non_interactive_consent() -> NonInteractiveConsent { - let state = ConsentState::load_state(); - if state.accepted_eula && state.accepted_tos && state.accepted_pp { - NonInteractiveConsent::Accepted - } else { - NonInteractiveConsent::RequiresInteractiveAcceptance - } -} - -async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), String> { - if state.accepted_eula && state.accepted_tos && state.accepted_pp { +async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { + if state.accepted_eula { return Ok(()); } - let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| { - "Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string() - })?; - let (tx, rx) = oneshot::channel(); - ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx)))) + ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx)))) .await; let result = rx.await.unwrap_or(UserChoice::Deny); match result { UserChoice::AcceptEULA | UserChoice::AcceptAll => { - state.accepted_eula = true; - state.eula = Some(current_eula); + if let Some((eula, tos, privacy)) = get_current_docs().await { + state.accepted_eula = true; + state.eula = Some(eula); - if matches!(result, UserChoice::AcceptAll) { - state.accepted_tos = true; - state.accepted_pp = true; - state.tos = Some(current_tos); - state.privacy = Some(current_privacy); + if matches!(result, UserChoice::AcceptAll) { + state.accepted_tos = true; + state.accepted_pp = true; + state.tos = Some(tos); + state.privacy = Some(privacy); + } } let _ = &state.save_state(); Ok(()) } - UserChoice::Deny => Ok(()), + UserChoice::Deny => Err(()), } } -/* -async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { +async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { return Ok(()); }; @@ -104,9 +96,7 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), Str UserChoice::AcceptEULA => { state.accepted_eula = true; } - UserChoice::Deny => { - return Err("Consent update was denied for a mandatory document.".to_string()); - } + UserChoice::Deny => return Err(()), } } else { apply_future_updates(state, result, eula_update, tos_update, privacy_update); @@ -115,8 +105,6 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), Str state.save_state(); Ok(()) } -*/ -/* fn apply_future_updates( state: &mut ConsentState, result: UserChoice, @@ -227,7 +215,20 @@ async fn get_updates() -> Option<( None } } -*/ + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum UserChoice { + Deny, + AcceptEULA, + AcceptAll, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateDecision { + NoChange, + Future { newest: Doc }, + Forced(Doc), +} #[derive(Debug, Clone)] pub struct ConsentState { @@ -302,7 +303,7 @@ impl ConsentState { if let Some(eula) = &self.eula { file_out.push_str(&format!("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.methanium.net/tensamin/eula\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\ \nEULA={}\ \nEULA-VERSION={}\ \nEULA-HASH={}\ @@ -312,7 +313,7 @@ impl ConsentState { && let Some(tos) = &self.tos { file_out.push_str(&format!("\ - \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.methanium.net/tensamin/terms-of-service\ + \n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.tensamin.net/tos/\ \nTerms-of-Service={}\ \nTerms-of-Service-VERSION={}\ \nTerms-of-Service-HASH={}\ @@ -322,7 +323,7 @@ impl ConsentState { && let Some(pp) = &self.privacy { file_out.push_str(&format!("\ - \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.methanium.net/tensamin/privacy-policy\ + \n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy/\ \nPrivacy-Policy={}\ \nPrivacy-Policy-VERSION={}\ \nPrivacy-Policy-HASH={}\ @@ -330,7 +331,7 @@ impl ConsentState { } } else { file_out.push_str("\ - \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.methanium.net/tensamin/eula\ + \n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.tensamin.net/eula/\ \nEULA=false\ "); } diff --git a/src/terms/doc.rs b/src/terms/doc.rs new file mode 100644 index 0000000..2365ba3 --- /dev/null +++ b/src/terms/doc.rs @@ -0,0 +1,73 @@ +use json::{JsonValue, object::Object}; + +use crate::{terms::terms_getter::Type, util::file_util::load_file}; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(unused)] +pub struct Doc { + version: String, + hash: String, + pub doc_type: Type, + timestamp: u64, +} + +#[allow(dead_code)] +impl Doc { + pub fn new(version: String, hash: String, doc_type: Type, timestamp: u64) -> Doc { + Doc { + version, + hash, + doc_type, + timestamp, + } + } + + pub fn equals_some(&self, other: &Option) -> bool { + if let Some(other) = other { + self.get_version() == other.get_version() && self.get_hash() == other.get_hash() + } else { + false + } + } + pub fn equals(&self, other: &Self) -> bool { + self.get_version() == other.get_version() && self.get_hash() == other.get_hash() + } + + pub fn get_version(&self) -> String { + self.version.clone() + } + pub fn get_hash(&self) -> String { + self.hash.clone() + } + pub fn get_time(&self) -> u64 { + self.timestamp.clone() + } + pub fn get_content(&self) -> String { + load_file( + format!("docs/{}/", self.doc_type.to_str()).as_str(), + format!("{}.md", self.version).as_str(), + ) + } + + pub fn to_json(&self) -> JsonValue { + let mut json = JsonValue::new_object(); + + let _ = json.insert("version", self.version.clone()); + let _ = json.insert("hash", self.hash.clone()); + let _ = json.insert("unix", self.timestamp.clone()); + + json + } + pub fn from_json(doc_type: Type, json: Object) -> Option { + let hash = json.get("hash")?.as_str()?.to_string(); + let version = json.get("version")?.as_str()?.to_string(); + let timestamp = json.get("unix")?.as_u64()?; + + Some(Doc { + version, + hash, + doc_type, + timestamp, + }) + } +} diff --git a/iota-cli/src/util/terms_focus.rs b/src/terms/focus.rs similarity index 100% rename from iota-cli/src/util/terms_focus.rs rename to src/terms/focus.rs diff --git a/src/terms/mod.rs b/src/terms/mod.rs new file mode 100644 index 0000000..4a11008 --- /dev/null +++ b/src/terms/mod.rs @@ -0,0 +1,5 @@ +pub mod buttons; +pub mod consent_state; +pub mod doc; +pub mod focus; +pub mod terms_getter; diff --git a/src/terms/terms_getter.rs b/src/terms/terms_getter.rs new file mode 100755 index 0000000..a0fa53b --- /dev/null +++ b/src/terms/terms_getter.rs @@ -0,0 +1,105 @@ +use json::JsonValue::Object; + +use crate::terms::doc::Doc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Type { + EULA, + TOS, + PP, +} + +impl Type { + pub fn to_str(&self) -> &str { + match self { + Self::EULA => "eula", + Self::TOS => "tos", + Self::PP => "privacy", + } + } + pub fn to_string(&self) -> String { + match self { + Self::EULA => "End User License Agreement".to_string(), + Self::TOS => "Terms of Service".to_string(), + Self::PP => "Privacy Policy".to_string(), + } + } +} + +pub fn get_link(terms_type: Type) -> String { + format!("https://legal.tensamin.net/{}/", terms_type.to_str()) +} +pub fn get_newest_link(terms_type: Type) -> String { + format!("https://legal.tensamin.net/{}/newest/", terms_type.to_str()) +} + +pub async fn get_current_docs() -> Option<(Doc, Doc, Doc)> { + let body = reqwest::get("https://legal.tensamin.net/api/current/") + .await + .ok()? + .text() + .await + .ok()?; + + let json = json::parse(&body).ok()?; + + if let Object(eula) = &json["eula"] { + if let Object(tos) = &json["tos"] { + if let Object(pp) = &json["pp"] { + Some(( + Doc::from_json(Type::EULA, eula.clone())?, + Doc::from_json(Type::TOS, tos.clone())?, + Doc::from_json(Type::PP, pp.clone())?, + )) + } else { + None + } + } else { + None + } + } else { + None + } +} + +pub async fn get_newest_docs() -> Option<(Doc, Doc, Doc)> { + let body = reqwest::get("https://legal.tensamin.net/api/newest/") + .await + .ok()? + .text() + .await + .ok()?; + + let json = json::parse(&body).ok()?; + + if let Object(eula) = &json["eula"] { + if let Object(tos) = &json["tos"] { + if let Object(pp) = &json["pp"] { + Some(( + Doc::from_json(Type::EULA, eula.clone())?, + Doc::from_json(Type::TOS, tos.clone())?, + Doc::from_json(Type::PP, pp.clone())?, + )) + } else { + None + } + } else { + None + } + } else { + None + } +} +pub async fn get_terms(terms_type: Type) -> Option { + let body = reqwest::get(format!( + "https://legal.tensamin.net/api/text/{}/", + terms_type.to_str() + )) + .await + .ok()? + .text() + .await + .ok()?; + + Some(body) +} diff --git a/src/users/contact.rs b/src/users/contact.rs new file mode 100644 index 0000000..632cea3 --- /dev/null +++ b/src/users/contact.rs @@ -0,0 +1,61 @@ +use json::{self, JsonValue, number::Number}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone)] +pub struct Contact { + pub user_id: i64, + pub user_name: Option, + pub last_message_at: Option, +} + +impl Default for Contact { + fn default() -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + Contact { + user_id: 0, + user_name: None, + last_message_at: Some(now), + } + } +} + +impl Contact { + pub fn new(user_id: i64) -> Self { + Contact { + user_id: user_id, + user_name: None, + last_message_at: None, + } + } + pub fn set_last_message_at(&mut self, p0: i64) { + self.last_message_at = Option::from(p0); + } + + pub fn to_json(&self) -> JsonValue { + let mut obj = JsonValue::new_object(); + obj["user_id"] = JsonValue::Number(Number::from(self.user_id)); + if let Some(name) = &self.user_name { + obj["user_name"] = JsonValue::from(name.as_str()); + } + if let Some(ts) = &self.last_message_at { + obj["last_message_at"] = JsonValue::Number(Number::from(*ts)); + } + obj + } + pub fn from_json(o: &JsonValue) -> Contact { + let user_id = o["user_id"].as_i64().unwrap_or(0); + + let user_name = o["user_name"].as_str().map(|s| s.to_string()); + + let last_message_at = o["last_message_at"].as_i64(); + + Contact { + user_id, + user_name, + last_message_at, + } + } +} diff --git a/iota-storage/src/users/mod.rs b/src/users/mod.rs similarity index 64% rename from iota-storage/src/users/mod.rs rename to src/users/mod.rs index dad90f9..aef5a02 100644 --- a/iota-storage/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,4 +1,4 @@ -pub mod contact; -pub mod pending_operations; -pub mod user_manager; +pub mod contact; +pub mod user_community_util; +pub mod user_manager; pub mod user_profile; diff --git a/src/users/user_community_util.rs b/src/users/user_community_util.rs new file mode 100644 index 0000000..45b036c --- /dev/null +++ b/src/users/user_community_util.rs @@ -0,0 +1,67 @@ +use crate::util::file_util::save_file; +use json::{self, Array, JsonValue}; +use std::fs; +use std::path::Path; + +pub struct UserCommunityUtil; + +impl UserCommunityUtil { + pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { + let file_path = format!("users/{}/", storage_owner); + let mut communities = Self::load_array(&file_path); + + let mut community = JsonValue::new_object(); + community["title"] = JsonValue::String(title); + community["address"] = JsonValue::String(address); + community["position"] = JsonValue::String(position); + + communities.push(community); + + save_file( + &file_path, + "communities.json", + &JsonValue::Array(communities).to_string(), + ); + } + + pub fn remove_community(storage_owner: i64, community_address: String) { + let file_path = format!("users/{}/", storage_owner); + let communities = Self::load_array(&file_path); + + let filtered: Array = communities + .iter() + .filter(|entry| entry["address"].as_str() != Some(&community_address)) + .cloned() + .collect(); + save_file( + &file_path, + "communities.json", + &JsonValue::Array(filtered).to_string(), + ); + } + + pub fn get_communities(storage_owner: i64) -> Array { + let file_path = format!("users/{}/communities.json", storage_owner); + Self::load_array(&file_path) + } + + fn load_array(file_path: &str) -> Array { + if !Path::new(file_path).exists() { + return Array::new(); + } + + match fs::read_to_string(file_path) { + Ok(content) => { + let parsed = json::parse(&content); + match parsed { + Ok(JsonValue::Array(arr)) => arr, + _ => Array::new(), + } + } + Err(err) => { + eprintln!("Failed to read file {}: {}", file_path, err); + Array::new() + } + } + } +} diff --git a/src/users/user_manager.rs b/src/users/user_manager.rs new file mode 100644 index 0000000..bcb5e26 --- /dev/null +++ b/src/users/user_manager.rs @@ -0,0 +1,197 @@ +use crate::omikron::omikron_connection::OMIKRON_CONNECTION; +use crate::users::user_profile::UserProfile; +use crate::util::crypto_helper::{self, public_key_to_base64}; +use crate::util::file_util::{load_file, save_file}; +use crate::util::logger::PrintType; +use crate::{RELOAD, SHUTDOWN}; +use crate::{log, log_cv}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use hex::{self}; +use json::JsonValue; +use once_cell::sync::Lazy; +use rand::Rng; +use rand_core::OsRng; +use rand_core::RngCore; +use sha2::{Digest, Sha256}; +use std::io::{self}; +use std::sync::Mutex; +use std::time::Duration; +use ttp_core::{CommunicationType, CommunicationValue, DataTypes, DataValue}; +use x448::{PublicKey, Secret}; + +static USERS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); +static UNIQUE: Lazy> = Lazy::new(|| Mutex::new(false)); + +#[allow(dead_code)] +pub async fn load_from_tu(username: &str) -> Result<(), ()> { + let file_content = load_file("", &format!("{}.tu", username)); + let segments = file_content.split("::").collect::>(); + let uuid = segments[0].parse::().unwrap_or(0); + let b64_private_key = segments[1]; + + let secret: Secret = crypto_helper::load_secret_key(b64_private_key).unwrap(); + let public_key = PublicKey::from(&secret); + + let mut bytes = [0u8; 192]; + OsRng.fill(bytes.as_mut()); + let reset_token = STANDARD.encode(&bytes); + + let user_profile = UserProfile::new( + uuid, + username.to_string(), + Some(username.to_string()), + crypto_helper::public_key_to_base64(&public_key), + crypto_helper::hex_hash(b64_private_key), + reset_token, + ); + USERS.lock().unwrap().push(user_profile); + Ok(()) +} + +pub async fn create_user(username: &str) -> (Option, Option) { + let register_cv = CommunicationValue::new(CommunicationType::get_register); + + let conn = OMIKRON_CONNECTION.clone(); + + let response_cv = match conn + .await_response(®ister_cv, Some(Duration::from_secs(20))) + .await + { + Ok(cv) => cv, + Err(_) => return (None, None), + }; + log_cv!(PrintType::Omega, response_cv); + + let user_id = match response_cv.get_data(DataTypes::user_id).as_number() { + Some(id) => id, + None => return (None, None), + }; + let mut buf = [0u8; 56]; + let mut rng = OsRng; + rng.fill_bytes(&mut buf); + let private_key = Secret::from_bytes(&buf).unwrap(); + let public_key = PublicKey::from(&private_key); + + let mut hasher = Sha256::new(); + hasher.update(&STANDARD.encode(&private_key.as_bytes()).as_bytes()); + let result = hasher.finalize(); + let private_key_hash = hex::encode(result); + + let mut bytes = [0u8; 192]; + OsRng.fill(bytes.as_mut()); + let reset_token = STANDARD.encode(&bytes); + + let up = UserProfile::new( + user_id, + username.to_string(), + None, + STANDARD.encode(&public_key.as_bytes()), + private_key_hash, + reset_token.clone(), + ); + + let cv = CommunicationValue::new(CommunicationType::complete_register_user) + .add_data(DataTypes::user_id, DataValue::Number(user_id)) + .add_data(DataTypes::username, DataValue::Str(username.to_string())) + .add_data( + DataTypes::public_key, + DataValue::Str(public_key_to_base64(&public_key)), + ) + .add_data(DataTypes::iota_id, DataValue::Number(user_id)) + .add_data(DataTypes::reset_token, DataValue::Str(reset_token)); + + let response_cv = conn + .await_response(&cv, Some(Duration::from_secs(20))) + .await; + + if let Ok(resp) = response_cv { + log_cv!(PrintType::Omega, resp); + if !resp.is_type(CommunicationType::success) { + return (None, None); + } + } else { + return (None, None); + } + *SHUTDOWN.write().await = true; + *RELOAD.write().await = true; + log!("Created User"); + save_file( + "", + &format!("{}.tu", username), + &format!("{}::{}", user_id, STANDARD.encode(&private_key.as_bytes())), + ); + + USERS.lock().unwrap().push(up.clone()); + save_users(); + (Some(up), Some(STANDARD.encode(&private_key.as_bytes()))) +} + +pub fn get_user_by_username(username: &str) -> Option { + USERS + .lock() + .unwrap() + .iter() + .cloned() + .find(|u| u.username == username) +} + +pub fn get_user(user_id: i64) -> Option { + USERS + .lock() + .unwrap() + .iter() + .cloned() + .find(|u| u.user_id == user_id) +} + +pub fn get_users() -> Vec { + USERS.lock().unwrap().clone() +} + +pub fn remove_user(user_id: i64) { + let mut users = USERS.lock().unwrap(); + users.retain(|u| u.user_id != user_id); + *UNIQUE.lock().unwrap() = true; +} + +pub fn save_users() { + *UNIQUE.lock().unwrap() = false; + let users = USERS.lock().unwrap(); + let arr: Vec = users.iter().map(|u| u.to_json()).collect(); + let json_str = JsonValue::Array(arr).dump(); + + save_file("", "users.json", &json_str); +} + +pub fn clear() { + let mut users = USERS.lock().unwrap(); + users.clear(); + *UNIQUE.lock().unwrap() = true; +} + +pub async fn load_users() -> io::Result<()> { + let content = load_file("", "users.json"); + if content.trim().is_empty() { + return Ok(()); + } + + let parsed = + json::parse(&content).map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + if let JsonValue::Array(arr) = parsed { + let mut users = USERS.lock().unwrap(); + for j in arr.iter() { + if let Some(up) = UserProfile::from_json(j).await { + users.push(up); + } + } + } + if *UNIQUE.lock().unwrap() { + save_users(); + } + Ok(()) +} + +#[allow(dead_code)] +pub fn set_unique(val: bool) { + *UNIQUE.lock().unwrap() = val; +} diff --git a/iota-storage/src/users/user_profile.rs b/src/users/user_profile.rs similarity index 58% rename from iota-storage/src/users/user_profile.rs rename to src/users/user_profile.rs index f1aa2a3..5d60277 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/src/users/user_profile.rs @@ -1,13 +1,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use crate::util::file_util::{has_file, load_file, used_dir_space}; use base64::{Engine as _, engine::general_purpose}; -use iota_util::file_util::{read_user_credential_with_legacy, used_dir_space}; use json::{JsonValue, object}; use rand::Rng; use rand::rngs::OsRng; -use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Serialize, Deserialize)] +// --- UserProfile --- +#[derive(Clone, Debug)] pub struct UserProfile { pub user_id: i64, pub username: String, @@ -16,7 +16,6 @@ pub struct UserProfile { pub reset_token: String, pub created_at: i64, pub display_name: Option, - pub trusted_apps: std::collections::HashMap, } impl UserProfile { @@ -27,29 +26,6 @@ impl UserProfile { public_key: String, private_key_hash: String, reset_token: String, - ) -> Self { - Self::new_with_created_at( - user_id, - username, - display_name, - public_key, - private_key_hash, - reset_token, - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64, - ) - } - - pub fn new_with_created_at( - user_id: i64, - username: String, - display_name: Option, - public_key: String, - private_key_hash: String, - reset_token: String, - created_at: i64, ) -> Self { Self { user_id, @@ -57,12 +33,28 @@ impl UserProfile { display_name, public_key, private_key_hash, - created_at, + created_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64, reset_token, - trusted_apps: std::collections::HashMap::new(), } } + pub fn to_json(&self) -> JsonValue { + let mut obj = object! { + "uuid" => self.user_id, + "username" => self.username.clone(), + "public_key" => self.public_key.clone(), + "private_key_hash" => self.private_key_hash.clone(), + "created_at" => self.created_at, + "reset_token" => self.reset_token.clone() + }; + if let Some(d) = &self.display_name { + obj["display_name"] = d.clone().into(); + } + obj + } pub fn frontend(&self) -> JsonValue { let mut obj = object! { "uuid" => self.user_id, @@ -75,16 +67,13 @@ impl UserProfile { if let Some(d) = &self.display_name { obj["display_name"] = d.clone().into(); } - // Frontend consumers must never receive private credential material. - obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username) - .map(|credential| credential.is_some()) - .unwrap_or(false) - .into(); + if has_file("", &format!("{}.tu", self.username.clone())) { + obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into(); + } + obj } - - /// Legacy JSON import - used when migrating from users.json to SQLite. - pub fn from_json(j: &JsonValue) -> Option { + pub async fn from_json(j: &JsonValue) -> Option { let user_id = j["uuid"].as_i64()?; let username = j["username"].as_str()?.to_string(); let public_key = j["public_key"].as_str()?.to_string(); @@ -93,16 +82,7 @@ impl UserProfile { let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); - let mut trusted_apps = std::collections::HashMap::new(); - if j["trusted_apps"].is_object() { - for (key, value) in j["trusted_apps"].entries() { - if let Some(s) = value.as_str() { - trusted_apps.insert(key.to_string(), s.to_string()); - } - } - } - - Some(UserProfile { + let up = UserProfile { user_id, username, display_name, @@ -110,16 +90,22 @@ impl UserProfile { private_key_hash, created_at, reset_token, - trusted_apps, - }) - } + }; - pub fn from_yaml(s: &str) -> Result { - serde_yaml::from_str(s) - } + // TODO: Migrate to Omikron / Wss + /* if j.has_key("migrate") + || j.has_key("migrating") + || j.has_key("changing") + || j.has_key("move") + || j.has_key("moving") + { + if auth_connector::migrate_user(&mut up).await { + log_message(format!("[INFO] Migration triggered for {}", up.username)); + user_manager::set_unique(true); + } + } */ - pub fn to_yaml(&self) -> Result { - serde_yaml::to_string(self) + Some(up) } #[allow(dead_code)] diff --git a/src/util/chat_files.rs b/src/util/chat_files.rs new file mode 100644 index 0000000..e54fe29 --- /dev/null +++ b/src/util/chat_files.rs @@ -0,0 +1,276 @@ +use crate::log; +use crate::util::db; +use json::{JsonValue, array, object}; +use rusqlite::params; +use std::io; +use std::sync::{Arc, LazyLock, Mutex}; + +#[derive(PartialEq, Debug, Clone)] +pub enum MessageState { + Read, + Received, + Sent, + Sending, +} + +impl MessageState { + pub fn as_str(&self) -> &'static str { + match self { + MessageState::Read => "read", + MessageState::Received => "received", + MessageState::Sent => "sent", + MessageState::Sending => "sending", + } + } + + pub fn from_str(value: &str) -> Self { + match value.to_lowercase().as_str() { + "read" => MessageState::Read, + "received" => MessageState::Received, + "sent" => MessageState::Sent, + _ => MessageState::Sending, + } + } + + pub fn upgrade(self, other: Self) -> Self { + if other == Self::Read || self == Self::Read { + Self::Read + } else if other == Self::Received || self == Self::Received { + Self::Received + } else if other == Self::Sent || self == Self::Sent { + Self::Sent + } else { + Self::Sending + } + } +} + +// Shared DB created via helper. +// The db helper constructs the messages sqlite file and ensures PRAGMAs and schema exist. +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") +}); + +pub fn add_message( + send_time: u128, + storage_owner_is_sender: bool, + storage_owner: i64, + external_user: i64, + message: &str, + height: i64, +) { + let message_time = match i64::try_from(send_time) { + Ok(v) => v, + Err(_) => { + log!("Failed to store message: send_time out of range for i64 ({send_time})"); + return; + } + }; + + // Insert the message into the DB + let insert_result = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO messages ( + storage_owner, + external_user, + message_time, + content, + sent_by_self, + message_state, + height + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "#, + params![ + storage_owner, + external_user, + message_time, + message, + if storage_owner_is_sender { + 1_i64 + } else { + 0_i64 + }, + MessageState::Sending.as_str(), + height, + ], + )?; + Ok(()) + }); + + if let Err(e) = insert_result { + log!("Failed to insert message into sqlite: {}", e); + return; + } + + // Update contacts table to reflect that this conversation exists and has a recent message. + // Use the Contact helper to set last_message_at to the message timestamp. + let mut contact = crate::users::contact::Contact::new(external_user); + contact.set_last_message_at(message_time); + // This will insert or update the contact for the storage owner. + crate::util::chats_util::mod_user(storage_owner, &contact); +} + +pub fn change_message_state( + timestamp: i64, + storage_owner: i64, + external_user: i64, + new_state: MessageState, +) -> io::Result<()> { + // Run the SELECT and UPDATE inside with_conn to centralize connection access. + let res: Result<(), String> = db::with_conn(&MESSAGES_DB, |conn| { + let current: Option = match conn.query_row( + r#" + SELECT message_state + FROM messages + WHERE storage_owner = ?1 + AND external_user = ?2 + AND message_time = ?3 + ORDER BY id DESC + LIMIT 1 + "#, + params![storage_owner, external_user, timestamp], + |row| row.get(0), + ) { + Ok(state) => Some(state), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(e) => return Err(e), + }; + + let Some(current_state_raw) = current else { + return Ok(()); + }; + + let upgraded = MessageState::from_str(¤t_state_raw) + .upgrade(new_state) + .as_str() + .to_string(); + + conn.execute( + r#" + UPDATE messages + SET message_state = ?1 + WHERE id = ( + SELECT id + FROM messages + WHERE storage_owner = ?2 + AND external_user = ?3 + AND message_time = ?4 + ORDER BY id DESC + LIMIT 1 + ) + "#, + params![upgraded, storage_owner, external_user, timestamp], + )?; + Ok(()) + }); + + match res { + Ok(_) => Ok(()), + Err(e) => Err(io::Error::new(io::ErrorKind::Other, e)), + } +} + +pub fn get_messages( + storage_owner: i64, + external_user: i64, + loaded_messages: i64, + amount: i64, +) -> JsonValue { + let messages = array![]; + + if amount <= 0 || loaded_messages < 0 { + return messages; + } + + let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT + message_time, + content, + sent_by_self, + message_state, + height + FROM messages + WHERE storage_owner = ?1 + AND external_user = ?2 + ORDER BY message_time DESC, id DESC + LIMIT ?3 OFFSET ?4 + "#, + )?; + + let rows = stmt.query_map( + params![storage_owner, external_user, amount, loaded_messages], + |row| { + let message_time: i64 = row.get(0)?; + let content: String = row.get(1)?; + let sent_by_self: i64 = row.get(2)?; + let message_state: String = row.get(3)?; + let height: i64 = row.get(4).unwrap_or(0); + Ok((message_time, content, sent_by_self, message_state, height)) + }, + )?; + + let mut out = array![]; + for row in rows { + match row { + Ok((message_time, content, sent_by_self, message_state, height)) => { + let msg = object! { + "message_time" => message_time, + "content" => content, + "sent_by_self" => (sent_by_self != 0), + "message_state" => message_state, + "height" => height + }; + if let Err(e) = out.push(msg) { + // out.push returns a JsonError; log it instead of using `?` to avoid + // incompatible error conversions inside the DB closure. + log!("Failed to append message to output array: {:?}", e); + } + } + Err(e) => { + log!("Failed to read row from sqlite: {}", e); + } + } + } + Ok(out) + }); + + match res { + Ok(v) => v, + Err(e) => { + log!("Failed to query messages: {}", e); + messages + } + } +} + +#[cfg(test)] +mod tests { + use super::MessageState; + + #[test] + fn upgrade_prefers_highest_state() { + assert_eq!( + MessageState::Sending.upgrade(MessageState::Sent), + MessageState::Sent + ); + assert_eq!( + MessageState::Sent.upgrade(MessageState::Received), + MessageState::Received + ); + assert_eq!( + MessageState::Received.upgrade(MessageState::Read), + MessageState::Read + ); + } + + #[test] + fn from_str_is_case_insensitive() { + assert_eq!(MessageState::from_str("READ"), MessageState::Read); + assert_eq!(MessageState::from_str("received"), MessageState::Received); + assert_eq!(MessageState::from_str("Sent"), MessageState::Sent); + assert_eq!(MessageState::from_str("unknown"), MessageState::Sending); + } +} diff --git a/src/util/chats_util.rs b/src/util/chats_util.rs new file mode 100644 index 0000000..b8e0ad2 --- /dev/null +++ b/src/util/chats_util.rs @@ -0,0 +1,121 @@ +use crate::users::contact::Contact; +use crate::util::db; +use rusqlite::params; +use std::sync::{Arc, LazyLock, Mutex}; + +/// Shared DB connection for contacts/messages (created by db helper). +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") +}); + +/// Insert or update a contact for the given storage owner. +pub fn mod_user(storage_owner: i64, contact: &Contact) { + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO contacts ( + storage_owner, + user_id, + user_name, + last_message_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(storage_owner, user_id) DO UPDATE SET + user_name = excluded.user_name, + last_message_at = excluded.last_message_at + "#, + params![ + storage_owner, + contact.user_id, + contact.user_name.clone(), + contact.last_message_at + ], + )?; + Ok(()) + }) { + eprintln!("Failed to mod_user: {}", e); + } +} + +/// Retrieve a single contact for storage_owner/user_id. +pub fn get_user(storage_owner: i64, user_id: i64) -> Option { + let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + match conn.query_row( + r#" + SELECT user_id, user_name, last_message_at + FROM contacts + WHERE storage_owner = ?1 AND user_id = ?2 + LIMIT 1 + "#, + params![storage_owner, user_id], + |r| { + let user_id: i64 = r.get(0)?; + let user_name: Option = r.get(1)?; + let last_message_at: Option = r.get(2)?; + Ok(Contact { + user_id, + user_name, + last_message_at, + }) + }, + ) { + Ok(c) => Ok(Some(c)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + }); + + match res { + Ok(opt) => opt, + Err(e) => { + eprintln!("Error querying user in get_user: {}", e); + None + } + } +} + +/// Retrieve all contacts for a storage owner, ordered by last_message_at desc / user_id asc. +pub fn get_users(storage_owner: i64) -> Vec { + let contacts_out = Vec::new(); + + let res: Result, String> = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT user_id, user_name, last_message_at + FROM contacts + WHERE storage_owner = ?1 + ORDER BY + CASE WHEN last_message_at IS NULL THEN 1 ELSE 0 END, + last_message_at DESC, + user_id ASC + "#, + )?; + + let rows = stmt.query_map(params![storage_owner], |r| { + let user_id: i64 = r.get(0)?; + let user_name: Option = r.get(1)?; + let last_message_at: Option = r.get(2)?; + Ok(Contact { + user_id, + user_name, + last_message_at, + }) + })?; + + let mut out = Vec::new(); + for row in rows { + match row { + Ok(contact) => out.push(contact), + Err(e) => eprintln!("Failed to read contact row: {}", e), + } + } + Ok(out) + }); + + match res { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to query contacts in get_users: {}", e); + contacts_out + } + } +} diff --git a/src/util/communities_util.rs b/src/util/communities_util.rs new file mode 100644 index 0000000..ed8b7ae --- /dev/null +++ b/src/util/communities_util.rs @@ -0,0 +1,90 @@ +use crate::util::db; +use json::Array; +use rusqlite::params; +use std::sync::{Arc, LazyLock, Mutex}; + +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + db::create_general_messages_db().expect("Failed to create or initialize general messages DB") +}); + +pub struct CommunitiesUtil; + +impl CommunitiesUtil { + pub fn add_community(storage_owner: i64, address: String, title: String, position: String) { + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + r#" + INSERT INTO communities ( + storage_owner, + address, + title, + position + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(storage_owner, address) DO UPDATE SET + title = excluded.title, + position = excluded.position + "#, + params![storage_owner, address, title, position], + )?; + Ok(()) + }) { + eprintln!("Failed to add_community: {}", e); + } + } + + pub fn remove_community(storage_owner: i64, community_address: String) { + if let Err(e) = db::with_conn(&MESSAGES_DB, |conn| { + conn.execute( + "DELETE FROM communities WHERE storage_owner = ?1 AND address = ?2", + params![storage_owner, community_address], + )?; + Ok(()) + }) { + eprintln!("Failed to remove_community: {}", e); + } + } + + pub fn get_communities(storage_owner: i64) -> Array { + let communities_out = Array::new(); + + let res: Result = db::with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare( + r#" + SELECT address, title, position + FROM communities + WHERE storage_owner = ?1 + "#, + )?; + + let rows = stmt.query_map(params![storage_owner], |r| { + let address: String = r.get(0)?; + let title: String = r.get(1)?; + let position: String = r.get(2)?; + Ok((address, title, position)) + })?; + + let mut out = Array::new(); + for row in rows { + match row { + Ok((address, title, position)) => { + let mut community = json::JsonValue::new_object(); + community["title"] = json::JsonValue::String(title); + community["address"] = json::JsonValue::String(address); + community["position"] = json::JsonValue::String(position); + out.push(community); + } + Err(e) => eprintln!("Failed to read community row: {}", e), + } + } + Ok(out) + }); + + match res { + Ok(arr) => arr, + Err(e) => { + eprintln!("Failed to query communities in get_communities: {}", e); + communities_out + } + } + } +} diff --git a/src/util/config_util.rs b/src/util/config_util.rs new file mode 100644 index 0000000..6330b59 --- /dev/null +++ b/src/util/config_util.rs @@ -0,0 +1,60 @@ +use crate::util::file_util::{load_file, save_file}; +use json::JsonValue; +use once_cell::sync::Lazy; +use tokio::sync::RwLock; + +pub static CONFIG: Lazy> = Lazy::new(|| RwLock::new(ConfigUtil::new())); + +pub struct ConfigUtil { + pub config: JsonValue, + pub unique: bool, +} + +impl ConfigUtil { + pub fn new() -> Self { + Self { + config: JsonValue::new_object(), + unique: false, + } + } + pub fn clear(&mut self) { + self.config = JsonValue::new_object(); + } + pub fn load(&mut self) { + let s = load_file("", "config.json"); + if !s.is_empty() { + self.config = json::parse(&s).unwrap_or(JsonValue::new_object()); + } + } + + pub fn get_iota_id(&self) -> i64 { + self.config["iota_id"].as_i64().unwrap_or(0) + } + + pub fn get_port(&self) -> u16 { + self.config["port"].as_u16().unwrap_or(1984) + } + + pub fn get_public_key(&self) -> Option { + self.config["public_key"].as_str().map(String::from) + } + + pub fn get_private_key(&self) -> Option { + self.config["private_key"].as_str().map(String::from) + } + + pub fn get(&self, key: &str) -> &JsonValue { + &self.config[key] + } + + pub fn change(&mut self, key: &str, value: JsonValue) { + self.config[key] = value; + self.unique = true; + } + + pub fn update(&mut self) { + if self.unique { + save_file("", "config.json", &self.config.to_string()); + } + } +} diff --git a/src/util/crypto_helper.rs b/src/util/crypto_helper.rs new file mode 100644 index 0000000..4a7fd76 --- /dev/null +++ b/src/util/crypto_helper.rs @@ -0,0 +1,133 @@ +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit, OsRng}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use rand_core::RngCore; +use sha2::{Digest, Sha256}; +use x448::{PublicKey, Secret, SharedSecret}; + +/// Errors for crypto opertions +#[derive(Debug)] +#[allow(dead_code)] +pub enum CryptoError { + Base64Decode(base64::DecodeError), + InvalidKey, + AgreementError, + EncryptionError(aes_gcm::Error), + DecryptionError(aes_gcm::Error), +} + +impl From for CryptoError { + fn from(err: base64::DecodeError) -> Self { + CryptoError::Base64Decode(err) + } +} + +pub struct KeyPair { + pub secret: Secret, + pub public: PublicKey, +} + +pub fn generate_keypair() -> KeyPair { + let mut buf = [0u8; 56]; + let mut rng = OsRng; + rng.fill_bytes(&mut buf); + let secret = Secret::from_bytes(&buf).unwrap(); + let public = PublicKey::from(&secret); + KeyPair { secret, public } +} + +pub fn public_key_to_base64(pubkey: &PublicKey) -> String { + STANDARD.encode(pubkey.as_bytes().as_ref()) +} + +pub fn secret_key_to_base64(secret: &Secret) -> String { + STANDARD.encode(secret.as_bytes().as_ref()) +} + +pub fn load_public_key(base64_pub: &str) -> Option { + let bytes = STANDARD.decode(base64_pub).unwrap(); + PublicKey::from_bytes(&bytes) +} + +pub fn load_secret_key(base64_secret: &str) -> Option { + let bytes = STANDARD.decode(base64_secret).unwrap(); + Secret::from_bytes(&bytes) +} + +#[allow(dead_code)] +fn derive_aes_key(shared: &SharedSecret) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(shared.as_bytes()); + let result = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&result[..32]); + key +} + +#[allow(dead_code)] +pub fn encrypt( + base64_secret: &str, + base64_peer_pub: &str, + plaintext: &str, +) -> Result { + let secret = load_secret_key(base64_secret).unwrap(); + let peer_pub = load_public_key(base64_peer_pub).unwrap(); + let shared = secret + .to_diffie_hellman(&peer_pub) + .ok_or(CryptoError::AgreementError)?; + let key_bytes = derive_aes_key(&shared); + let cipher = Aes256Gcm::new_from_slice(&key_bytes).expect("Key length should be correct"); + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(CryptoError::EncryptionError)?; + // prefix nonce to ciphertext + let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(STANDARD.encode(&out)) +} + +#[allow(dead_code)] +pub fn decrypt( + base64_secret: &str, + base64_peer_pub: &str, + encrypted_base64: &str, +) -> Result { + let secret = load_secret_key(base64_secret).unwrap(); + let peer_pub = load_public_key(base64_peer_pub).unwrap(); + let shared = secret + .to_diffie_hellman(&peer_pub) + .ok_or(CryptoError::AgreementError)?; + let key_bytes = derive_aes_key(&shared); + let cipher = Aes256Gcm::new_from_slice(&key_bytes).expect("Key length should be correct"); + + let encrypted = STANDARD.decode(encrypted_base64)?; + if encrypted.len() < 12 { + return Err(CryptoError::DecryptionError(aes_gcm::Error)); + } + let nonce_bytes = &encrypted[..12]; + let ciphertext = &encrypted[12..]; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext_bytes = cipher + .decrypt(nonce, ciphertext) + .map_err(CryptoError::DecryptionError)?; + let plaintext = String::from_utf8(plaintext_bytes) + .map_err(|_| CryptoError::DecryptionError(aes_gcm::Error))?; + Ok(plaintext) +} + +pub fn hash_it(input: &str) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + hasher.finalize().to_vec() +} + +pub fn hex_hash(input: &str) -> String { + let digest = hash_it(input); + digest.iter().map(|b| format!("{:02x}", b)).collect() +} diff --git a/src/util/crypto_util.rs b/src/util/crypto_util.rs new file mode 100644 index 0000000..bb784e8 --- /dev/null +++ b/src/util/crypto_util.rs @@ -0,0 +1,178 @@ +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit, Payload}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STD}; +use hkdf::Hkdf; +type HkdfSha256 = sha2::Sha256; +use sha2::{Digest, Sha256 as HashSha256}; +use x448::{PublicKey, Secret}; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum SecurePayloadError { + InvalidBase64, + InvalidHex, + EncryptionError, + DecryptionError, + InvalidKeyLength, +} + +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub enum DataFormat { + Raw, + Base64, + Hex, +} + +pub struct SecurePayload { + inner_data: Vec, + private_key: Secret, +} + +impl Clone for SecurePayload { + fn clone(&self) -> Self { + Self { + inner_data: self.inner_data.clone(), + private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), + } + } +} + +#[allow(dead_code)] +impl SecurePayload { + pub fn new>( + data: T, + format: DataFormat, + private_key: S, + ) -> Result + where + S: Into, + { + let raw_data = match format { + DataFormat::Raw => data.as_ref().to_vec(), + DataFormat::Base64 => BASE64_STD + .decode(data.as_ref()) + .map_err(|_| SecurePayloadError::InvalidBase64)?, + DataFormat::Hex => { + hex::decode(data.as_ref()).map_err(|_| SecurePayloadError::InvalidHex)? + } + }; + + Ok(Self { + inner_data: raw_data, + private_key: private_key.into(), + }) + } + + pub fn get_public_key(&self) -> [u8; 56] { + *PublicKey::from(&self.private_key).as_bytes() + } + + pub fn export(&self, format: DataFormat) -> String { + match format.into() { + DataFormat::Raw => String::from_utf8_lossy(&self.inner_data).to_string(), + DataFormat::Base64 => BASE64_STD.encode(&self.inner_data), + DataFormat::Hex => hex::encode(&self.inner_data), + } + } + + pub fn get_bytes(&self) -> &[u8] { + &self.inner_data + } + + pub fn get_hash(&self, format: DataFormat) -> String { + let mut hasher = HashSha256::new(); + hasher.update(&self.inner_data); + let result = hasher.finalize(); + + match format { + DataFormat::Raw => String::from_utf8_lossy(&result).to_string(), + DataFormat::Base64 => BASE64_STD.encode(result), + DataFormat::Hex => hex::encode(result), + } + } + + pub fn encrypt_x448(&self, public_key: S) -> Result + where + S: Into, + { + let peer_pub = public_key.into(); + let shared_secret = self.private_key.as_diffie_hellman(&peer_pub).unwrap(); + + let hkdf = Hkdf::::new(None, shared_secret.as_bytes()); + let mut okm = [0u8; 44]; + + hkdf.expand(b"x448-aes-gcm-no-overhead", &mut okm) + .map_err(|_| SecurePayloadError::EncryptionError)?; + + let key = &okm[..32]; + let nonce_bytes = &okm[32..]; + + let cipher = Aes256Gcm::new(key.into()); + let nonce = Nonce::from_slice(nonce_bytes); + + let ciphertext = cipher + .encrypt( + nonce, + Payload { + msg: &self.inner_data, + aad: &[], + }, + ) + .map_err(|_| SecurePayloadError::EncryptionError)?; + + Ok(SecurePayload { + inner_data: ciphertext, + private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), + }) + } + + pub fn decrypt_to_format( + &self, + peer_public_key_bytes: &[u8; 56], + output_format: DataFormat, + ) -> Result { + let decrypted_instance = + self.decrypt_x448(PublicKey::from_bytes(peer_public_key_bytes).unwrap())?; + Ok(decrypted_instance.export(output_format)) + } + + pub fn decrypt_x448( + &self, + peer_public_key_bytes: S, + ) -> Result + where + S: Into, + { + let peer_pub = peer_public_key_bytes.into(); + let shared_secret = self.private_key.as_diffie_hellman(&peer_pub).unwrap(); + + let hkdf = Hkdf::::new(None, shared_secret.as_bytes()); + let mut okm = [0u8; 44]; + hkdf.expand(b"x448-aes-gcm-no-overhead", &mut okm) + .map_err(|_| SecurePayloadError::DecryptionError)?; + + let key = &okm[..32]; + let nonce_bytes = &okm[32..]; + + let cipher = Aes256Gcm::new(key.into()); + let nonce = Nonce::from_slice(nonce_bytes); + + let plaintext = cipher + .decrypt( + nonce, + Payload { + msg: &self.inner_data, + aad: &[], + }, + ) + .map_err(|_| SecurePayloadError::DecryptionError)?; + + Ok(SecurePayload { + inner_data: plaintext, + private_key: Secret::from_bytes(self.private_key.as_bytes()).unwrap(), + }) + } +} diff --git a/src/util/db.rs b/src/util/db.rs new file mode 100644 index 0000000..9fb40a0 --- /dev/null +++ b/src/util/db.rs @@ -0,0 +1,185 @@ +//! Database helper utilities. +//! +//! This module provides small helpers to open/init sqlite databases and to +//! create a shared (Arc>) connection wrapper callers can +//! reuse. The goal is to centralize the "open and initialize" logic and +//! provide small convenience helpers used by other util modules. + +use crate::util::file_util::get_directory; +use rusqlite::{Connection, Error as RusqliteError}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Returns the file path for a named DB inside the application's data directory. +/// +/// Arguments: +/// - `db_name` : name of the DB (without extension). Example: `"messages"`. +pub fn db_file_path(db_name: &str) -> String { + let mut p = PathBuf::from(get_directory()); + p.push(format!("{db_name}.sqlite3")); + p.to_string_lossy().to_string() +} + +/// Open a sqlite connection to the named DB file (no initialization). +/// +/// Arguments: +/// - `db_name`: name of the DB (without extension). +pub fn open_connection(db_name: &str) -> Result { + let path = db_file_path(db_name); + Connection::open(path) +} + +/// Open a connection and immediately run `init_sql` via `execute_batch`. +/// +/// Arguments: +/// - `db_name`: name of the DB (without extension). +/// - `init_sql`: SQL statements to initialize schema & PRAGMAs (can be multiple). +pub fn open_and_init(db_name: &str, init_sql: &str) -> Result { + let conn = open_connection(db_name)?; + conn.execute_batch(init_sql)?; + Ok(conn) +} + +/// Create a shared, Arc> initialized with the given SQL. +/// +/// This is a convenience wrapper that returns an owned Arc> +/// so caller modules can store it in a `static` or pass it around. +/// +/// Arguments: +/// - `db_name`: DB name (without extension). +/// - `init_sql`: init SQL (eg PRAGMA + CREATE TABLE statements). +pub fn create_shared_connection( + db_name: &str, + init_sql: &str, +) -> Result>, String> { + match open_and_init(db_name, init_sql) { + Ok(conn) => { + // Configure some sensible defaults for concurrency + // Attempt to set a busy timeout to reduce SQLITE_BUSY failures. + let _ = conn.busy_timeout(Duration::from_millis(250)); + Ok(Arc::new(Mutex::new(conn))) + } + Err(e) => Err(format!("Failed to open/init DB '{}': {}", db_name, e)), + } +} + +/// Acquire the Connection from an Arc> and run the provided +/// closure. Converts rusqlite::Error into a String on error. +/// +/// Arguments: +/// - `shared`: Arc> +/// - `f`: closure that receives &Connection and returns Result +/// +/// Returns Ok(T) or Err(String). +pub fn with_conn(shared: &Arc>, f: F) -> Result +where + F: FnOnce(&Connection) -> Result, +{ + // When invoked from within an async runtime (such as Tokio), taking a blocking + // std::sync::Mutex lock on the runtime thread can cause deadlocks or permanent + // awaits. Detect whether we're running inside a Tokio runtime and, if so, + // execute the blocking lock + database closure using Tokio's blocking helper. + // + // The blocking section returns Result so we can propagate errors + // in the same form as before. + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| { + let guard = shared + .lock() + .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; + f(&*guard).map_err(|e| e.to_string()) + }) + } else { + let guard = shared + .lock() + .map_err(|e| format!("DB mutex poisoned: {:?}", e))?; + f(&*guard).map_err(|e| e.to_string()) + } +} + +/// Initialize a general-purpose messages+contacts DB and return a shared +/// connection. This helper creates a single DB file that can contain multiple +/// tables (messages, contacts, ...). The SQL here is conservative and intended +/// to be safe if called multiple times. +/// +/// Callers may prefer to call `create_shared_connection("messages", INIT_SQL)` +/// directly, but this convenience is useful for code that expects both tables. +pub fn create_general_messages_db() -> Result>, String> { + // Keep PRAGMA and schema in one multi-statement string so callers only + // need to call a single execute_batch. + const INIT_SQL: &str = r#" + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + external_user INTEGER NOT NULL, + message_time INTEGER NOT NULL, + content TEXT NOT NULL, + sent_by_self INTEGER NOT NULL, + message_state TEXT NOT NULL, + height INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_messages_lookup + ON messages (storage_owner, external_user, message_time DESC); + + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + user_id INTEGER NOT NULL, + user_name TEXT, + last_message_at INTEGER, + UNIQUE(storage_owner, user_id) + ); + + CREATE INDEX IF NOT EXISTS idx_contacts_owner + ON contacts (storage_owner, last_message_at DESC, user_id ASC); + + CREATE TABLE IF NOT EXISTS communities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + address TEXT NOT NULL, + title TEXT NOT NULL, + position TEXT NOT NULL, + UNIQUE(storage_owner, address) + ); + + CREATE INDEX IF NOT EXISTS idx_communities_owner + ON communities (storage_owner); + "#; + + match create_shared_connection("messages", INIT_SQL) { + Ok(shared_conn) => { + // Attempt to add the height column for backwards compatibility. + // This will fail if the column already exists, which is expected. + let _ = with_conn(&shared_conn, |conn| { + let _ = conn.execute( + "ALTER TABLE messages ADD COLUMN height INTEGER NOT NULL DEFAULT 0", + [], + ); + Ok(()) + }); + Ok(shared_conn) + } + Err(e) => Err(e), + } +} + +/* +Example usage: + +// In some util module (at init time, e.g. lazy_static or LazyLock) +static MESSAGES_DB: LazyLock>> = LazyLock::new(|| { + create_general_messages_db().expect("failed to create messages DB") +}); + +// Later, to run a query: +let res: Result, String> = with_conn(&MESSAGES_DB, |conn| { + let mut stmt = conn.prepare("SELECT ...")?; + let rows = stmt.query_map(...)?; + // collect and return Ok(...) +}); +*/ diff --git a/iota-util/src/file_util.rs b/src/util/file_util.rs similarity index 51% rename from iota-util/src/file_util.rs rename to src/util/file_util.rs index 0232148..2d00718 100755 --- a/iota-util/src/file_util.rs +++ b/src/util/file_util.rs @@ -3,13 +3,14 @@ use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, BufReader, Read}; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; use sysinfo::System; use tokio::io::AsyncWriteExt; use uuid::Uuid; use walkdir::WalkDir; use zip::ZipArchive; +use crate::log; + #[allow(dead_code)] pub fn delete_directory(path: &str) -> bool { let dir = Path::new(&get_directory()).join(path); @@ -22,7 +23,7 @@ fn delete_dir_recursive(directory: &Path) -> bool { return false; } if let Err(e) = fs::remove_dir_all(directory) { - println!( + log!( "[IMPORTANT] Couldn't delete directory {}: {}", directory.display(), e, @@ -33,126 +34,42 @@ fn delete_dir_recursive(directory: &Path) -> bool { } #[allow(dead_code)] -pub fn delete_user_directory(user_id: i64) -> io::Result<()> { +pub fn delete_user_directory(user_id: i64) { let user_dir = Path::new(&get_directory()) .join("users") .join(user_id.to_string()); - if !user_dir.exists() { - return Ok(()); - } - fs::remove_dir_all(user_dir) -} - -fn credential_filename(username: &str) -> io::Result { - if username.is_empty() - || username.chars().any(char::is_control) - || username.contains(['/', '\\']) - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "unsafe credential owner name", - )); - } - Ok(format!("{username}.tu")) -} - -pub fn credential_path(username: &str) -> io::Result { - credential_path_in(&storage_directory(), username) -} - -fn credential_path_in(root: &Path, username: &str) -> io::Result { - Ok(root - .join("credentials") - .join(credential_filename(username)?)) -} - -fn legacy_credential_path(user_id: i64) -> io::Result { - storage_file("credentials", format!("{user_id}.tu")) -} - -pub fn read_user_credential(username: &str) -> io::Result> { - let path = credential_path(username)?; - match fs::read_to_string(path) { - Ok(value) => Ok(Some(value)), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error), - } -} - -/* Resolve a credential by account id while using the owner's name for the - * canonical filename. Older ID-based and root-level files are migrated when - * they are encountered. */ -pub fn read_user_credential_with_legacy( - user_id: i64, - username: &str, -) -> io::Result> { - let canonical_path = credential_path(username)?; - if let Some(credential) = read_user_credential(username)? { - return Ok(Some(credential)); - } - let legacy_paths = [ - legacy_credential_path(user_id)?, - storage_file("", credential_filename(username)?)?, - ]; - for legacy_path in legacy_paths { - let credential = match fs::read_to_string(&legacy_path) { - Ok(value) => value, - Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - Err(error) => return Err(error), - }; - let parsed = crate::tu::TuCredential::parse(&credential) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - if parsed.user_id != user_id { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "legacy credential user id mismatch", - )); - } - write_user_credential(username, &parsed.to_canonical_string())?; - if legacy_path != canonical_path { - fs::remove_file(legacy_path)?; - } - return Ok(Some(parsed.to_canonical_string())); - } - Ok(None) -} - -pub fn write_user_credential(username: &str, credential: &str) -> io::Result<()> { - let path = credential_path(username)?; - crate::atomic_file::replace_private(&path, credential.as_bytes(), 0) -} - -pub fn remove_user_credential(user_id: i64, username: Option<&str>) -> io::Result<()> { - let mut paths = vec![legacy_credential_path(user_id)?]; - if let Some(username) = username { - paths.push(credential_path(username)?); - paths.push(storage_file("", credential_filename(username)?)?); - } - - for path in paths { - match fs::remove_file(path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - } - Ok(()) + let _ = delete_dir_recursive(&user_dir); } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { - let file_path = storage_file(path, name)?; + let dir = Path::new(&get_directory()).join(path); + let file_path = dir.join(name); + + // Ensure the directory exists, create if necessary + if !dir.exists() { + if let Err(_) = fs::create_dir_all(&dir) { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Directory creation failed", + )); + } + } + + // Create the file if it doesn't exist + if !file_path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "File creation failed", + )); + } // Open the file and return a BufReader for efficient reading let file = File::open(&file_path)?; Ok(BufReader::new(file)) } pub fn has_file(path: &str, name: &str) -> bool { - let Ok(file_path) = storage_file(path, name) else { - return false; - }; - let Some(dir) = file_path.parent() else { - return false; - }; + let dir = Path::new(&get_directory()).join(path); + let file_path = dir.join(name); if !dir.exists() { return false; @@ -165,9 +82,7 @@ pub fn has_file(path: &str, name: &str) -> bool { true } pub fn has_dir(path: &str) -> bool { - let Ok(dir) = storage_child(path) else { - return false; - }; + let dir = Path::new(&get_directory()).join(path); if !dir.exists() { return false; @@ -177,18 +92,21 @@ pub fn has_dir(path: &str) -> bool { } pub fn load_file(path: &str, name: &str) -> String { - let Ok(file_path) = storage_file(path, name) else { - return String::new(); - }; - let Some(dir) = file_path.parent() else { - return String::new(); - }; + let dir = Path::new(&get_directory()).join(path); + let file_path = dir.join(name); if !dir.exists() { + if let Err(e) = fs::create_dir_all(&dir) { + log!("[IMPORTANT] Couldn't create directories: {}", e); + return String::new(); + } return String::new(); } if !file_path.exists() { + if let Err(e) = File::create(&file_path) { + log!("[IMPORTANT] Couldn't create file: {}", e); + } return String::new(); } @@ -200,38 +118,34 @@ pub fn load_file(path: &str, name: &str) -> String { } pub fn load_file_vec(path: &str, name: &str) -> Result, std::io::Error> { - std::fs::read(storage_file(path, name)?) + let dir = Path::new(&get_directory()).join(path); + let file_path = dir.join(name); + + std::fs::read(file_path) } pub fn save_file(path: &str, name: &str, value: &str) { - if let Err(error) = try_save_file(path, name, value) { - eprintln!("[IMPORTANT] Couldn't save file: {error}"); + let dir = Path::new(&get_directory()).join(path); + let file_path = dir.join(name); + + if !dir.exists() { + if let Err(e) = fs::create_dir_all(&dir) { + log!("[IMPORTANT] Couldn't create directories: {}", e); + return; + } } -} -pub fn try_save_file(path: &str, name: &str, value: &str) -> io::Result<()> { - let file_path = storage_file(path, name)?; - let dir = file_path - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file has no parent"))?; - - fs::create_dir_all(dir)?; - - // Write to a temp file first, then atomically rename to prevent partial writes. - let tmp_name = format!(".{}.tmp", name); - let tmp_path = dir.join(&tmp_name); - fs::write(&tmp_path, value)?; - if let Err(error) = fs::rename(&tmp_path, &file_path) { - let _ = fs::remove_file(&tmp_path); - return Err(error); + if let Err(e) = fs::write(&file_path, value) { + log!( + "[IMPORTANT] Couldn't write file {}: {}", + file_path.display(), + e + ); } - Ok(()) } pub fn get_children(path: &str) -> Vec { - let Ok(dir) = storage_child(path) else { - return Vec::new(); - }; + let dir = Path::new(&get_directory()).join(path); let mut children = Vec::new(); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries { @@ -243,61 +157,12 @@ pub fn get_children(path: &str) -> Vec { children } -static STORAGE_DIRECTORY: OnceLock = OnceLock::new(); - -/// Set by the daemon immediately after resolving `IotaPaths`. This keeps the -/// legacy storage helpers working while preventing them from independently -/// discovering a different (user-scope) directory in a system daemon. -pub fn configure_storage_directory(path: PathBuf) { - let _ = STORAGE_DIRECTORY.set(path); -} - -pub fn storage_directory() -> PathBuf { - STORAGE_DIRECTORY.get().cloned().unwrap_or_else(|| { - iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .expect("resolve Iota user paths") - .storage_dir - }) -} - -/// Resolve a user supplied storage fragment without allowing it to escape the -/// resolved storage root. Legacy call sites may use nested fragments, but -/// never absolute paths or `..` components. -pub fn storage_child(path: impl AsRef) -> io::Result { - let path = path.as_ref(); - if path.is_absolute() - || path.components().any(|c| { - matches!( - c, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "unsafe storage path", - )); - } - Ok(storage_directory().join(path)) -} - -pub fn storage_file(path: impl AsRef, name: impl AsRef) -> io::Result { - let name = name.as_ref(); - if name.components().count() != 1 || name.is_absolute() || name == Path::new(".") { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "unsafe storage file name", - )); - } - storage_child(path).map(|dir| dir.join(name)) -} - pub fn get_directory() -> String { - // Legacy helpers are storage-only. Configuration, keys, logs and runtime - // files must use their dedicated path APIs instead. - storage_directory().to_string_lossy().into_owned() + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); + exe.parent() + .unwrap_or(Path::new(".")) + .to_string_lossy() + .to_string() } // Helper to download the zip file content to a file on disk @@ -366,7 +231,7 @@ pub async fn download_zip(url: &str, zip_path: &Path) -> Result<(), Box true, Err(e) => { - println!("Error during ZIP extraction: {}", e); + log!("Error during ZIP extraction: {}", e); false } }; if let Err(e) = tokio::fs::remove_file(&zip_path).await { - println!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); + log!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); } else if successful { - println!("Downloaded and extracted ZIP file successfully."); - } -} - -#[cfg(test)] -mod tests { - use super::credential_path_in; - use std::path::Path; - - #[test] - fn credential_path_uses_owner_name() { - let path = credential_path_in(Path::new("/tmp/iota"), "alice").unwrap(); - assert!(path.ends_with("credentials/alice.tu")); - assert!(!path.ends_with("credentials/42.tu")); - } - - #[test] - fn credential_path_rejects_unsafe_owner_name() { - assert!(credential_path_in(Path::new("/tmp/iota"), "../alice").is_err()); - assert!(credential_path_in(Path::new("/tmp/iota"), "alice/bob").is_err()); + log!("Downloaded and extracted ZIP file successfully."); } } diff --git a/iota-logger/src/lib.rs b/src/util/logger.rs old mode 100644 new mode 100755 similarity index 59% rename from iota-logger/src/lib.rs rename to src/util/logger.rs index b704910..a5feb02 --- a/iota-logger/src/lib.rs +++ b/src/util/logger.rs @@ -1,21 +1,23 @@ use std::{ + collections::BTreeMap, fs::{self, OpenOptions}, io::Write, + path::Path, sync::{OnceLock, atomic::Ordering, mpsc}, thread, time::{SystemTime, UNIX_EPOCH}, }; -use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version}; use ratatui::style::Color; +use ttp_core::{CommunicationValue, DataTypes, DataValue}; -use iota_state::{UNIQUE, UiLogEntry}; -use tokio::sync::broadcast; -pub mod language_creator; -pub mod language_manager; +use crate::{ + APP_STATE, + gui::{elements::log_card::LogEntry, ui::UNIQUE}, + langu::language_manager, +}; static LOGGER: OnceLock> = OnceLock::new(); -static LOG_BROADCASTER: OnceLock> = OnceLock::new(); #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[allow(unused)] @@ -52,35 +54,25 @@ struct LogMessage { message: Option, } -/* The logger owns file persistence while consumers receive rendered entries - * through a process-local broadcast subscription. */ pub fn startup() { - startup_with_log_dir(Some( - iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .expect("resolve Iota user paths") - .log_dir, - )); -} - -/// `None` keeps logging on stderr only (the systemd default). -pub fn startup_with_log_dir(log_dir: Option) { let (tx, rx) = mpsc::channel::(); - if LOGGER.set(tx).is_err() { - return; - } - let (broadcast_tx, _) = broadcast::channel(512); - let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); + LOGGER.set(tx).expect("Logger already initialized"); thread::spawn(move || { - let mut file = log_dir.and_then(|log_dir| { - fs::create_dir_all(&log_dir).ok()?; - let start_ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); - OpenOptions::new() - .create(true) - .append(true) - .open(log_dir.join(format!("log_{start_ts}.txt"))) - .ok() - }); + let log_dir = Path::new("logs"); + fs::create_dir_all(log_dir).expect("Failed to create log directory"); + + let start_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let path = log_dir.join(format!("log_{}.txt", start_ts)); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("Failed to open log file"); for msg in rx { let resolved_message = if let Some(key) = msg.translation_key { @@ -91,6 +83,7 @@ pub fn startup_with_log_dir(log_dir: Option) { }; let timestamp = format_timestamp_inline(msg.timestamp_ms); + let entry = LogEntry::new(msg.kind, resolved_message, msg.is_error); let prefix = if msg.prefix.is_empty() { String::new() @@ -98,36 +91,22 @@ pub fn startup_with_log_dir(log_dir: Option) { format!("{} ", msg.prefix) }; - let line = format!( - "{} {} {}{}", + let _ = writeln!( + file, + "{} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), - timestamp, prefix, - resolved_message + entry.message ); - if let Some(file) = file.as_mut() { - let _ = writeln!(file, "{}", line); - } + let _ = writeln!(file, " {}", timestamp); - let _ = writeln!(std::io::stderr(), "{}", line); - - let entry = UiLogEntry { - timestamp_ms: msg.timestamp_ms, - sender: format!("{:?}", msg.kind), - message: resolved_message, - is_error: msg.is_error, - }; - - let _ = broadcast_tx.send(entry); + let mut state = APP_STATE.lock().unwrap(); + state.push_log(entry.into()); } }); } -pub fn subscribe() -> Option> { - LOG_BROADCASTER.get().map(broadcast::Sender::subscribe) -} - fn format_timestamp_inline(timestamp_ms: u128) -> String { let secs = (timestamp_ms / 1000) as i64; let hours = (secs / 3600) % 24; @@ -146,6 +125,22 @@ fn fixed_box(content: &str, width: usize) -> String { } } +fn colorize(kind: PrintType, is_error: bool) -> Color { + if is_error { + return Color::Red; + } + + match kind { + PrintType::Call => Color::Magenta, + PrintType::Client => Color::Green, + PrintType::Iota => Color::Yellow, + PrintType::Omikron => Color::Blue, + PrintType::Omega => Color::Cyan, + PrintType::General => Color::LightCyan, + PrintType::Command => Color::LightGreen, + } +} + pub fn log_internal_translated( kind: PrintType, prefix: String, @@ -191,8 +186,8 @@ pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: St #[macro_export] macro_rules! log_t { ($key:expr) => { - $crate::log_internal_translated( - $crate::PrintType::General, + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, "".to_string(), false, $key, @@ -201,8 +196,8 @@ macro_rules! log_t { }; ($key:expr, $($arg:expr),+) => { - $crate::log_internal_translated( - $crate::PrintType::General, + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, "".to_string(), false, $key, @@ -214,8 +209,8 @@ macro_rules! log_t { #[macro_export] macro_rules! log_t_err { ($key:expr) => { - $crate::log_internal_translated( - $crate::PrintType::General, + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, "".to_string(), true, $key, @@ -224,8 +219,8 @@ macro_rules! log_t_err { }; ($key:expr, $($arg:expr),+) => { - $crate::log_internal_translated( - $crate::PrintType::General, + $crate::util::logger::log_internal_translated( + $crate::util::logger::PrintType::General, "".to_string(), true, $key, @@ -238,8 +233,8 @@ macro_rules! log_t_err { #[macro_export] macro_rules! log_command { ($($arg:tt)*) => { - $crate::log_internal( - $crate::PrintType::Command, + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::Command, "".to_string(), false, format!($($arg)*) @@ -251,8 +246,8 @@ macro_rules! log_command { #[macro_export] macro_rules! log { ($($arg:tt)*) => { - $crate::log_internal( - $crate::PrintType::General, + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, "".to_string(), false, format!($($arg)*) @@ -264,8 +259,8 @@ macro_rules! log { #[macro_export] macro_rules! log_in { ($($arg:tt)*) => { - $crate::log_internal( - $crate::PrintType::General, + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, ">".to_string(), false, format!($($arg)*) @@ -277,8 +272,8 @@ macro_rules! log_in { #[macro_export] macro_rules! log_out { ($($arg:tt)*) => { - $crate::log_internal( - $crate::PrintType::General, + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, "<".to_string(), false, format!($($arg)*) @@ -290,8 +285,8 @@ macro_rules! log_out { #[macro_export] macro_rules! log_err { ($($arg:tt)*) => { - $crate::log_internal( - $crate::PrintType::General, + $crate::util::logger::log_internal( + $crate::util::logger::PrintType::General, ">>".to_string(), true, format!($($arg)*) @@ -318,52 +313,46 @@ pub fn log_cv_internal( pub fn format_cv(cv: &CommunicationValue) -> String { let mut parts = Vec::new(); - match (cv.sender(), cv.receiver()) { - (Some(sender), Some(receiver)) => parts.push(format!("{} > {}", sender, receiver)), - (Some(sender), None) => parts.push(sender.to_string()), - (None, Some(receiver)) => parts.push(format!("> {}", receiver)), - (None, None) => {} + let sender = cv.get_sender(); + let receiver = cv.get_receiver(); + + if sender > 0 && receiver > 0 { + parts.push(format!("{} > {}", sender, receiver)); + } else if sender > 0 { + parts.push(format!("{}", sender)); + } else if receiver > 0 { + parts.push(format!("> {}", receiver)); } - let comm_type = cv - .get_comm_type_enum() - .map(|kind| kind.to_string()) - .unwrap_or_else(|| cv.get_type().to_string()); - let id = cv - .id() - .map_or_else(|| "none".to_string(), |value| value.to_string()); - parts.push(format!("{} (id={})", comm_type, id)); + let comm_type = cv.get_type().to_string(); + parts.push(format!("{}", comm_type)); - let version = cv - .type_map() - .map(|type_map| type_map.version.clone()) - .unwrap_or_else(|| Version(3, 0)); - let formated_data = cv.data().map_or_else( - || "".to_string(), - |data| format_data_container(data.to_vec(), version), - ); + let data: &BTreeMap = cv.get_data_container(); + + let formated_data = + format_data_container(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); parts.push(format!("{}", formated_data)); parts.join(": ") } -fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) -> String { +fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { let parts: Vec = data .into_iter() .map(|(key, value)| { let key_str = key.to_string(); match value { - DataValue::Str(s) => format!("{}=\"{}\"", key_str, abbreviate_string(&s)), + DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner, version.clone()); + let inner_formatted = format_data_container(inner); format!("{}={{ {} }}", key_str, inner_formatted) } DataValue::Array(arr) => { - let arr_formatted = format_array(arr, version.clone()); + let arr_formatted = format_array(arr); format!("{}=[{}]", key_str, arr_formatted) } @@ -372,7 +361,7 @@ fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) - DataValue::BoolTrue => format!("{}=true", key_str), DataValue::BoolFalse => format!("{}=false", key_str), - DataValue::SignedNumber(num) => format!("{}={}", key_str, num), + DataValue::Number(num) => format!("{}={}", key_str, num), _ => "".to_string(), } @@ -382,19 +371,19 @@ fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) - parts.join(", ") } -fn format_array(arr: Vec, version: Version) -> String { +fn format_array(arr: Vec) -> String { let parts: Vec = arr .into_iter() .map(|value| match value { - DataValue::Str(s) => format!("\"{}\"", abbreviate_string(&s)), + DataValue::Str(s) => format!("\"{}\"", s), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner, version.clone()); + let inner_formatted = format_data_container(inner); format!("{{ {} }}", inner_formatted) } DataValue::Array(inner_arr) => { - let formatted = format_array(inner_arr, version.clone()); + let formatted = format_array(inner_arr); format!("[{}]", formatted) } @@ -403,7 +392,7 @@ fn format_array(arr: Vec, version: Version) -> String { DataValue::BoolTrue => "true".to_string(), DataValue::BoolFalse => "false".to_string(), - DataValue::SignedNumber(num) => num.to_string(), + DataValue::Number(num) => num.to_string(), _ => String::new(), }) @@ -412,57 +401,32 @@ fn format_array(arr: Vec, version: Version) -> String { parts.join(", ") } -fn abbreviate_string(value: &str) -> String { - const EDGE_LENGTH: usize = 4; - - let chars: Vec = value.chars().collect(); - if chars.len() <= EDGE_LENGTH * 2 { - return value.to_string(); - } - - let prefix: String = chars.iter().take(EDGE_LENGTH).collect(); - let suffix: String = chars.iter().rev().take(EDGE_LENGTH).rev().collect(); - format!("{prefix}...{suffix}") -} - -#[cfg(test)] -mod tests { - use super::abbreviate_string; - - #[test] - fn abbreviates_only_strings_longer_than_eight_characters() { - assert_eq!(abbreviate_string("12345678"), "12345678"); - assert_eq!(abbreviate_string("123456789"), "1234...6789"); - assert_eq!(abbreviate_string("YWJjZGVmZ2hpag=="), "YWJj...ag=="); - } -} - #[macro_export] macro_rules! log_cv { ($kind:expr, $cv:expr) => { - $crate::log_cv_internal("", &$cv, Some($kind)) + $crate::util::logger::log_cv_internal("", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::log_cv_internal("", &$cv, None) + $crate::util::logger::log_cv_internal("", &$cv, None) }; } #[macro_export] macro_rules! log_cv_in { ($kind:expr, $cv:expr) => { - $crate::log_cv_internal("> ", &$cv, Some($kind)) + $crate::util::logger::log_cv_internal("> ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::log_cv_internal("> ", &$cv, None) + $crate::util::logger::log_cv_internal("> ", &$cv, None) }; } #[macro_export] macro_rules! log_cv_out { ($kind:expr, $cv:expr) => { - $crate::log_cv_internal("< ", &$cv, Some($kind)) + $crate::util::logger::log_cv_internal("< ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::log_cv_internal("< ", &$cv, None) + $crate::util::logger::log_cv_internal("< ", &$cv, None) }; } diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..47901f9 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,9 @@ +pub mod chat_files; +pub mod chats_util; +pub mod communities_util; +pub mod config_util; +pub mod crypto_helper; +pub mod crypto_util; +pub mod db; +pub mod file_util; +pub mod logger; diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service deleted file mode 100644 index a5a2903..0000000 --- a/systemd/iota-daemon.service +++ /dev/null @@ -1,31 +0,0 @@ -[Unit] -Description=Tensamin Iota daemon -After=network-online.target -Wants=network-online.target -Requires=iota-daemon.socket - -[Service] -Type=simple -ExecStart=/usr/local/lib/iota/iota-daemon -User=iota -Group=iota -StateDirectory=iota -StateDirectoryMode=0750 -Restart=on-failure -RestartSec=5s -Environment=IOTA_SOCKET=/run/iota/iota.sock -Environment=IOTA_DATA_DIR=/var/lib/iota -Environment=IOTA_DEPLOYMENT_MODE=system_always_on -Environment=IOTA_SUPERVISOR=systemd - -# Exit code 75 = restart requested (daemon-specific convention) -RestartPreventExitStatus=0 -RestartForceExitStatus=75 - -# Graceful shutdown -TimeoutStopSec=10 -KillMode=mixed -KillSignal=SIGTERM - -[Install] -WantedBy=multi-user.target diff --git a/systemd/iota-daemon.socket b/systemd/iota-daemon.socket deleted file mode 100644 index 4030172..0000000 --- a/systemd/iota-daemon.socket +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Tensamin Iota daemon IPC socket - -[Socket] -ListenStream=/run/iota/iota.sock -SocketMode=0660 -SocketUser=iota -DirectoryMode=0755 -SocketGroup=iota-operators -Backlog=5 -RemoveOnStop=true -NonBlocking=true -# Enabling this socket starts the daemon on demand when a client connects. - -[Install] -WantedBy=sockets.target diff --git a/systemd/sysusers.d/iota.conf b/systemd/sysusers.d/iota.conf deleted file mode 100644 index 5a103af..0000000 --- a/systemd/sysusers.d/iota.conf +++ /dev/null @@ -1,2 +0,0 @@ -g iota-operators - -u iota - "Tensamin Iota daemon" /var/lib/iota diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml deleted file mode 100644 index c07501f..0000000 --- a/web-server/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ - -[package] -name = "web-server" -version = "0.1.0" -edition = "2024" - -[dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } -bytes = "1" -http = "1" -iota-logger = { path = "../iota-logger" } -tokio = { version = "1.50.0", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs deleted file mode 100644 index 9cbabb2..0000000 --- a/web-server/src/lib.rs +++ /dev/null @@ -1,154 +0,0 @@ -use bytes::Bytes; -use iota_logger::log; -use mtp::host::HostConfig; -use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig}; -use std::{net::IpAddr, path::PathBuf, sync::Arc}; -use tokio::sync::Mutex; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WebMode { - Disabled, - Loopback, - Network, -} - -#[derive(Clone, Debug)] -pub struct TlsConfig { - pub certificate: PathBuf, - pub key: PathBuf, -} - -#[derive(Clone, Debug)] -pub struct WebConfig { - pub mode: WebMode, - pub bind: IpAddr, - pub port: u16, - pub asset_dir: PathBuf, - pub tls: Option, - pub required: bool, -} - -#[derive(Debug)] -pub enum WebServerError { - Disabled, - MissingTls(String), - Io(String), - Startup(String), -} -impl std::fmt::Display for WebServerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") - } -} -impl std::error::Error for WebServerError {} - -pub struct WebServerHandle { - cancellation: CancellationToken, - join: Mutex>>, -} -impl WebServerHandle { - pub async fn shutdown(&self) { - self.cancellation.cancel(); - self.join().await; - } - pub async fn join(&self) { - if let Some(join) = self.join.lock().await.take() { - let _ = join.await; - } - } -} - -async fn root(asset_dir: PathBuf, _request: HttpRequest, response: HttpResponse) -> HttpResponse { - static_file(asset_dir, "index.html".into(), response).await -} -async fn static_file(asset_dir: PathBuf, path: String, response: HttpResponse) -> HttpResponse { - let file = path.trim_start_matches('/'); - let file = if file.is_empty() { "index.html" } else { file }; - if file.split('/').any(|component| component == "..") { - return response - .status(http::StatusCode::BAD_REQUEST) - .body("invalid path"); - } - let path = asset_dir.join(file); - let body = match tokio::fs::read(&path).await { - Ok(body) => body, - Err(_) => { - return response - .status(http::StatusCode::NOT_FOUND) - .body("not found"); - } - }; - let name = path.file_name().and_then(|v| v.to_str()).unwrap_or(""); - response - .status(http::StatusCode::OK) - .header("content-type", content_type(name)) - .body(Bytes::from(body)) -} -fn content_type(name: &str) -> &'static str { - match std::path::Path::new(name) - .extension() - .and_then(|e| e.to_str()) - { - Some("html") => "text/html; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("js") => "application/javascript; charset=utf-8", - Some("json") => "application/json", - Some("png") => "image/png", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - _ => "application/octet-stream", - } -} - -pub async fn start( - config: WebConfig, - parent: CancellationToken, -) -> Result>, WebServerError> { - if config.mode == WebMode::Disabled { - return Ok(None); - } - if config.mode == WebMode::Network && config.tls.is_none() { - return Err(WebServerError::MissingTls( - "network mode requires TLS".into(), - )); - } - let tls = config - .tls - .ok_or_else(|| WebServerError::MissingTls("certificate and key are required".into()))?; - let certificate = tokio::fs::read(&tls.certificate) - .await - .map_err(|e| WebServerError::Io(e.to_string()))?; - let key = tokio::fs::read(&tls.key) - .await - .map_err(|e| WebServerError::Io(e.to_string()))?; - let host_config = HostConfig::new(config.bind, config.port, certificate, key); - let assets = config.asset_dir.clone(); - let web_config = WebServerConfig::new() - .route("/", move |request, response| { - root(assets.clone(), request, response) - }) - .and_then(|web_config| { - let assets = config.asset_dir.clone(); - web_config.fallback(move |request, response| { - let path = request.uri.path().to_string(); - static_file(assets.clone(), path, response) - }) - }) - .map_err(|e| WebServerError::Startup(e.to_string()))?; - let mut server = MTPWebServer::new(host_config, web_config) - .await - .map_err(|e| WebServerError::Startup(e.to_string()))?; - let cancellation = parent.child_token(); - let task_cancellation = cancellation.clone(); - let join = tokio::spawn(async move { - loop { - tokio::select! { result = server.accept() => match result { Ok(Some(_)) => {}, Ok(None) => break, Err(error) => log!("MTP webserver connection failed: {}", error) }, _ = task_cancellation.cancelled() => { server.shutdown().await; break; } } - } - }); - Ok(Some(Arc::new(WebServerHandle { - cancellation, - join: Mutex::new(Some(join)), - }))) -} diff --git a/web-ui/Cargo.toml b/web-ui/Cargo.toml deleted file mode 100644 index 679a1f2..0000000 --- a/web-ui/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "web-ui" -version = "0.1.0" -edition = "2024" - -[dependencies] -iota-storage = { path = "../iota-storage" } -iota-state = { path = "../iota-state" } -iota-util = { path = "../iota-util" } -iota-logger = { path = "../iota-logger" } -iota-cli = { path = "../iota-cli" } -iota-ipc = { path = "../iota-ipc" } -iota-paths = { path = "../iota-paths" } - -actix-web = { version = "4", features = ["rustls-0_23"] } -rustls = { version = "0.23.37", features = ["aws-lc-rs"] } -rustls-pemfile = "2.2.0" -serde_json = "1.0.149" -tokio = { version = "1.50.0", features = ["full"] } diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs deleted file mode 100755 index 4359c17..0000000 --- a/web-ui/src/api.rs +++ /dev/null @@ -1,275 +0,0 @@ -use actix_web::{HttpRequest, HttpResponse, Responder, web}; -use iota_ipc::{IpcErrorCode, LocalRequest, ResponsePayload, ResponseResult}; -use iota_paths::{Scope, socket_path}; -use iota_state::DaemonState; -use iota_storage::util::config_util::{CONFIG, modify_config}; -use serde_json::{Value, json}; -use std::net::SocketAddr; -use std::sync::Arc; - -pub fn api_config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/api") - .route("/shutdown/", web::post().to(shutdown)) - .route("/reload/", web::post().to(reload)) - .route("/users/add/", web::post().to(users_add)) - .route("/users/remove/", web::post().to(users_remove)) - .route("/users/get/", web::get().to(users_get)) - .route("/communities/add/", web::post().to(communities_add)) - .route("/communities/get/", web::get().to(communities_get)) - .route("/settings/set/", web::post().to(settings_set)) - .route("/settings/get/", web::get().to(settings_get)), - ); -} - -async fn settings_set(req: HttpRequest, ssl: web::Data) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - let key = req.headers().get("key").and_then(|v| v.to_str().ok()); - let value = req.headers().get("value").and_then(|v| v.to_str().ok()); - - match (key, value) { - (Some(k), Some(v)) => { - modify_config(|cfg| match k { - "port" => { - if let Ok(port) = v.parse::() { - cfg.port = port; - } - } - "omikron_host" => { - cfg.omikron_host = Some(v.to_string()); - } - "omikron_port" => { - if let Ok(port) = v.parse::() { - cfg.omikron_port = Some(port); - } - } - "read_receipts_enabled" => { - cfg.read_receipts_enabled = v == "true"; - } - _ => {} - }); - success() - } - _ => error(), - } -} - -async fn settings_get(req: HttpRequest, ssl: web::Data) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - let serde_config: Value = serde_json::to_value(&**CONFIG.load()).unwrap(); - HttpResponse::Ok().json(serde_config) -} - -async fn communities_get(req: HttpRequest, ssl: web::Data) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - // let communities = decentralized::communities::community_manager::get_communities().await; - - let list: Vec = Vec::new(); - - // for c in communities { - // let val = c.frontend().await.to_string(); - // let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null); - // list.push(s_val); - // } - HttpResponse::Ok().json(list) -} - -async fn communities_add( - req: HttpRequest, - ssl: web::Data, - _payload: web::Json, -) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - // let name = payload["name"].as_str().unwrap_or("").to_string(); - // let owner = payload["owner"].as_i64().unwrap_or(0); - - // let community = - // Arc::new(decentralized::communities::community::Community::create(name, owner).await); - - // decentralized::communities::community_manager::add_community(community).await; - - success() -} - -async fn users_get(req: HttpRequest, ssl: web::Data) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - let users = iota_storage::users::user_manager::get_users(); - - let list: Vec<_> = users - .into_iter() - .map(|u| { - let val = u.frontend().to_string(); - serde_json::from_str(&val).unwrap_or(Value::Null) - }) - .collect(); - - HttpResponse::Ok().json(list) -} - -async fn users_remove( - req: HttpRequest, - ssl: web::Data, - payload: web::Json, -) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - let uuid = match user_id(&payload) { - Ok(uuid) => uuid, - Err(response) => return response, - }; - - iota_storage::users::user_manager::remove_user(uuid); - iota_storage::users::user_manager::save_users(); - - success() -} - -async fn users_add( - req: HttpRequest, - ssl: web::Data, - payload: web::Json, -) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - let username = match payload.get("username").and_then(|v| v.as_str()) { - Some(u) => u, - _ => return error(), - }; - - let client = match iota_cli::ipc_client::IpcClient::connect(socket_path(Scope::User)).await { - Ok(client) => client, - Err(_) => return HttpResponse::ServiceUnavailable().json(json!({ "status": "not_ready" })), - }; - match client - .send_request(LocalRequest::CreateUser { - username: username.to_string(), - }) - .await - { - Ok(ResponseResult::Ok(ResponsePayload::UserCreated { user_id, username })) => { - HttpResponse::Created().json(json!({ - "uuid": user_id, - "username": username, - "has_tu": true, - })) - } - Ok(ResponseResult::Ok(_)) => HttpResponse::Created().json(json!({ "status": "created" })), - Ok(ResponseResult::Error(code)) => ipc_error_response(code), - Err(_) => HttpResponse::GatewayTimeout().json(json!({ "status": "timeout" })), - } -} - -async fn shutdown( - req: HttpRequest, - ssl: web::Data, - state: web::Data>, -) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - *state.shutdown.write().await = true; - success() -} - -async fn reload( - req: HttpRequest, - ssl: web::Data, - state: web::Data>, -) -> impl Responder { - if !is_allowed_req(&req, *ssl.get_ref()) { - return forbidden(); - } - - *state.shutdown.write().await = true; - *state.reload.write().await = true; - - success() -} - -fn forbidden() -> HttpResponse { - HttpResponse::Forbidden().body("403 Forbidden") -} - -fn success() -> HttpResponse { - HttpResponse::Ok().json(json!({ "type": "success" })) -} - -fn error() -> HttpResponse { - HttpResponse::BadRequest().json(json!({ "type": "error" })) -} - -fn ipc_error_response(code: IpcErrorCode) -> HttpResponse { - let status = match code { - IpcErrorCode::InvalidRequest => actix_web::http::StatusCode::BAD_REQUEST, - IpcErrorCode::Conflict => actix_web::http::StatusCode::CONFLICT, - IpcErrorCode::NotReady | IpcErrorCode::OmikronUnavailable => { - actix_web::http::StatusCode::SERVICE_UNAVAILABLE - } - IpcErrorCode::Timeout => actix_web::http::StatusCode::GATEWAY_TIMEOUT, - IpcErrorCode::Unauthorized => actix_web::http::StatusCode::FORBIDDEN, - IpcErrorCode::StorageFailure | IpcErrorCode::InternalFailure => { - actix_web::http::StatusCode::INTERNAL_SERVER_ERROR - } - IpcErrorCode::NotFound => actix_web::http::StatusCode::NOT_FOUND, - IpcErrorCode::UnsupportedVersion | IpcErrorCode::Disconnected | IpcErrorCode::Cancelled => { - actix_web::http::StatusCode::SERVICE_UNAVAILABLE - } - }; - HttpResponse::build(status).json(json!({ "status": code.to_string() })) -} - -fn user_id(payload: &Value) -> Result { - payload - .get("uuid") - .and_then(Value::as_i64) - .ok_or_else(error) -} - -fn is_allowed(addr: SocketAddr, ssl: bool) -> bool { - let _ = ssl; - addr.ip().is_loopback() -} - -fn is_allowed_req(req: &HttpRequest, ssl: bool) -> bool { - if let Some(addr) = req.peer_addr() { - is_allowed(addr, ssl) - } else { - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn error_response_is_bad_request() { - assert_eq!(error().status(), actix_web::http::StatusCode::BAD_REQUEST); - } - - #[test] - fn user_id_rejects_missing_or_non_integer_uuid() { - assert!(user_id(&json!({})).is_err()); - assert!(user_id(&json!({ "uuid": "0" })).is_err()); - assert_eq!(user_id(&json!({ "uuid": 0 })).ok(), Some(0)); - } -} diff --git a/web-ui/src/socket.rs b/web-ui/src/socket.rs deleted file mode 100644 index e69de29..0000000