diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..46adaad --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[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 new file mode 100644 index 0000000..ec4e2af --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,130 @@ +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 deleted file mode 100644 index 4265c56..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,23 +0,0 @@ -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 0000aa1..9fb3aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ target logs +agreements +languages/ +.envrc +.direnv +config.json +*.mk +*.sqlite* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3069632 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[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 cd56dfb..e506091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,36 +2,13 @@ # 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.11.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-sink", @@ -44,24 +21,24 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.0" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-tls", "actix-utils", - "base64", - "bitflags 2.11.0", + "base64 0.22.1", + "bitflags 2.13.1", "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", "flate2", - "foldhash 0.1.5", + "foldhash", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -73,8 +50,8 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.2", - "sha1", + "rand 0.10.2", + "sha1 0.11.0", "smallvec", "tokio", "tokio-util", @@ -89,7 +66,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -109,9 +86,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "c25da0441692de4ad67950cb7ed6c9ce4b669a6609525e547566c2e1ab4d695c" dependencies = [ "futures-core", "tokio", @@ -119,9 +96,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "cc8dcb6fa613d47c3b764a7dc672b4b31f25751dcbcf542f7b16f3e1f700044c" dependencies = [ "actix-rt", "actix-service", @@ -129,7 +106,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -154,7 +131,7 @@ dependencies = [ "actix-service", "actix-utils", "futures-core", - "impl-more", + "impl-more 0.1.9", "pin-project-lite", "rustls-pki-types", "tokio", @@ -175,9 +152,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -195,10 +172,10 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash", "futures-core", "futures-util", - "impl-more", + "impl-more 0.3.5", "itoa", "language-tags", "log", @@ -211,30 +188,12 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2", "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" @@ -244,7 +203,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -259,7 +218,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -271,28 +230,14 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "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", + "cpufeatures 0.2.17", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -305,9 +250,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -320,18 +265,77 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] -name = "anyhow" -version = "1.0.102" +name = "anstream" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +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" +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", +] [[package]] name = "arbitrary" @@ -343,10 +347,31 @@ dependencies = [ ] [[package]] -name = "asn1-rs" -version = "0.7.1" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -354,7 +379,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -366,7 +391,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -378,18 +403,18 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -409,15 +434,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -426,14 +451,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -442,13 +468,25 @@ 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", + "bit-vec 0.6.3", ] [[package]] @@ -457,6 +495,15 @@ 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" @@ -465,9 +512,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +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", +] [[package]] name = "block-buffer" @@ -479,10 +535,19 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "8.0.2" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -491,9 +556,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -501,15 +566,21 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -519,15 +590,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ "bytes", ] @@ -552,9 +623,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -562,12 +633,6 @@ 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" @@ -576,15 +641,50 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +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", +] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -599,8 +699,63 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "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]] @@ -612,6 +767,18 @@ 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" @@ -624,9 +791,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -636,6 +803,18 @@ 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" @@ -697,6 +876,15 @@ 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" @@ -708,9 +896,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -722,19 +910,16 @@ dependencies = [ ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "critical-section" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -742,7 +927,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -774,6 +959,17 @@ 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" @@ -785,19 +981,62 @@ dependencies = [ ] [[package]] -name = "ctr" -version = "0.9.2" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cipher", + "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", ] [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -805,33 +1044,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -843,9 +1082,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deflate64" @@ -859,6 +1098,27 @@ 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" @@ -878,9 +1138,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_arbitrary" @@ -890,7 +1147,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -912,7 +1169,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -922,20 +1179,32 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -954,21 +1223,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "ed448-goldilocks" -version = "0.7.2" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "fiat-crypto", - "hex", + "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", "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.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" @@ -1027,16 +1334,34 @@ dependencies = [ ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "fastbloom" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" -version = "0.1.20" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "filedescriptor" @@ -1051,9 +1376,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "finl_unicode" @@ -1084,33 +1409,12 @@ 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" @@ -1128,9 +1432,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1143,9 +1447,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1153,15 +1457,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1170,38 +1474,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1244,41 +1548,30 @@ 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" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "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", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" @@ -1301,16 +1594,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -1318,21 +1611,68 @@ 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" @@ -1341,40 +1681,27 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "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", ] [[package]] name = "hashlink" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" 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", -] - -[[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", + "hashbrown 0.17.1", ] [[package]] @@ -1391,11 +1718,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", ] [[package]] @@ -1404,7 +1731,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "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", ] [[package]] @@ -1426,9 +1762,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1436,23 +1772,23 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -1470,17 +1806,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "1.9.0" +name = "hybrid-array" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "ctutils", + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2 0.4.18", + "http 1.5.0", "http-body", "httparse", "httpdate", @@ -1493,15 +1839,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.5.0", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1513,18 +1858,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -1558,9 +1903,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1572,9 +1917,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1585,9 +1930,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1599,16 +1944,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1619,15 +1965,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1638,12 +1984,6 @@ 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" @@ -1663,9 +2003,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1678,15 +2018,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] -name = "indexmap" -version = "2.13.0" +name = "impl-more" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -1709,69 +2053,254 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "iota" version = "0.1.0" dependencies = [ - "actix-web", - "actix-web-actors", - "aes-gcm", - "async-trait", - "base64", + "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 = [ "chrono", "crossterm", - "dashmap", - "futures", - "futures-util", - "hex", - "hkdf", - "hyper", - "hyper-util", - "json", - "lazy_static", + "iota-ipc", + "iota-paths", + "iota-state", + "iota-terms", "once_cell", "open", - "pnet", - "rand 0.8.5", - "rand_core 0.6.4", "ratatui", - "reqwest", - "rusqlite", - "rustls", - "rustls-pemfile", + "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", - "sha2", - "strum 0.27.2", - "strum_macros 0.27.2", + "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", + "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", - "tokio-tungstenite", - "ttp-core", - "ttp-native", - "tungstenite", +] + +[[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", + "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", + "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", + "sysinfo", + "tempfile", + "tokio", "uuid", "walkdir", - "warp", - "x448", "zip", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ipnetwork" @@ -1782,16 +2311,6 @@ 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" @@ -1811,6 +2330,12 @@ 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" @@ -1828,27 +2353,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.20", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", ] [[package]] @@ -1867,28 +2397,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1906,7 +2435,26 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "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", ] [[package]] @@ -1927,29 +2475,29 @@ 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.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "pkg-config", "vcpkg", @@ -1957,11 +2505,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -1972,9 +2520,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -2010,17 +2558,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2036,7 +2584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ "crc", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2051,9 +2599,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -2076,16 +2624,6 @@ 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" @@ -2104,9 +2642,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -2115,20 +2653,216 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "ml-dsa" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "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", ] [[package]] @@ -2137,7 +2871,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2171,9 +2905,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2181,9 +2915,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2193,14 +2927,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2229,7 +2963,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -2244,9 +2978,9 @@ dependencies = [ [[package]] name = "octets" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" +checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" [[package]] name = "oid-registry" @@ -2257,12 +2991,39 @@ 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" @@ -2271,39 +3032,12 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" 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]] @@ -2312,18 +3046,6 @@ 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" @@ -2333,6 +3055,43 @@ 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" @@ -2357,10 +3116,15 @@ dependencies = [ ] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "password-hash" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] [[package]] name = "pbkdf2" @@ -2368,8 +3132,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -2378,10 +3142,19 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "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" @@ -2390,9 +3163,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -2400,9 +3173,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -2410,25 +3183,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", - "sha2", ] [[package]] @@ -2458,7 +3230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.7", ] [[package]] @@ -2471,7 +3243,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2483,26 +3255,6 @@ 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" @@ -2510,10 +3262,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pnet" @@ -2560,7 +3332,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2607,28 +3379,27 @@ dependencies = [ ] [[package]] -name = "polyval" -version = "0.6.2" +name = "poly1305" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2654,40 +3425,31 @@ 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" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", + "socket2", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2695,21 +3457,24 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "fastbloom", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2717,23 +3482,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2751,24 +3516,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand" -version = "0.8.5" +name = "r2d2" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2781,22 +3558,6 @@ 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" @@ -2808,42 +3569,52 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", ] [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", - "strum 0.27.2", - "thiserror 2.0.18", + "palette", + "serde", + "strum", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2851,9 +3622,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -2863,19 +3634,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +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" dependencies = [ "ratatui-core", "termwiz", @@ -2883,18 +3665,19 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", - "strum 0.27.2", + "serde", + "strum", "time", "unicode-segmentation", "unicode-width", @@ -2902,14 +3685,16 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "aws-lc-rs", + "pem", + "ring", "rustls-pki-types", "time", - "x509-parser 0.18.1", + "x509-parser", "yasna", ] @@ -2919,14 +3704,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2936,9 +3721,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2953,22 +3738,22 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2 0.4.18", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -3011,21 +3796,21 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3036,9 +3821,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3064,7 +3849,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3073,9 +3858,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -3089,9 +3874,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3110,9 +3895,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -3120,9 +3905,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3147,9 +3932,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3159,9 +3944,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3188,10 +3973,13 @@ dependencies = [ ] [[package]] -name = "scoped-tls" -version = "1.0.1" +name = "scheduled-thread-pool" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] [[package]] name = "scopeguard" @@ -3205,7 +3993,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3224,15 +4012,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3240,29 +4028,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3284,14 +4072,38 @@ dependencies = [ ] [[package]] -name = "sha1" -version = "0.10.6" +name = "serde_yaml" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "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]] @@ -3301,15 +4113,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "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", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -3343,16 +4187,51 @@ dependencies = [ ] [[package]] -name = "simd-adler32" -version = "0.3.9" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +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" +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" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3362,35 +4241,51 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.5" 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" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -3416,31 +4311,13 @@ 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", + "strum_macros", ] [[package]] @@ -3452,7 +4329,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3474,9 +4351,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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" dependencies = [ "proc-macro2", "quote", @@ -3500,7 +4388,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3523,7 +4411,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3545,12 +4433,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "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" @@ -3579,8 +4480,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.11.0", + "base64 0.22.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3597,7 +4498,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -3625,11 +4526,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3640,28 +4541,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -3673,15 +4573,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3689,9 +4589,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3699,9 +4599,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3714,9 +4614,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3724,30 +4624,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" 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", + "syn 3.0.3", ] [[package]] @@ -3761,28 +4651,27 @@ dependencies = [ ] [[package]] -name = "tokio-tungstenite" -version = "0.29.0" +name = "tokio-stream" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ - "futures-util", - "log", - "native-tls", + "futures-core", + "pin-project-lite", "tokio", - "tokio-native-tls", - "tungstenite", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -3804,20 +4693,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3852,7 +4741,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3870,54 +4759,11 @@ 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://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 = "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" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -3925,12 +4771,6 @@ 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" @@ -3939,9 +4779,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -3972,10 +4812,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "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" @@ -4014,12 +4860,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -4064,33 +4910,6 @@ 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" @@ -4099,27 +4918,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -4130,9 +4940,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -4140,9 +4950,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4150,65 +4960,43 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +name = "web-server" +version = "0.1.0" 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.0", - "hashbrown 0.15.5", - "indexmap", - "semver", + "bytes", + "http 1.5.0", + "iota-logger", + "mtp", + "tokio", + "tokio-util", ] [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -4224,11 +5012,29 @@ 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.6" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -4251,7 +5057,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4389,7 +5195,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4400,7 +5206,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4448,31 +5254,13 @@ 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 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4484,52 +5272,20 @@ 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 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4541,243 +5297,71 @@ 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" 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" +version = "0.57.1" 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", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wtransport" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56b611f195638f3790e4e5a41e9d777643a6c324ae4ffd1f0f53f2738e7678c" +checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" dependencies = [ "bytes", "pem", @@ -4786,55 +5370,39 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pki-types", - "sha2", - "socket2 0.5.10", - "thiserror 2.0.18", + "sha2 0.11.0", + "socket2", + "thiserror 2.0.20", "time", "tokio", "tracing", "url", "wtransport-proto", - "x509-parser 0.17.0", + "x509-parser", ] [[package]] name = "wtransport-proto" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1627c5b59450278e9771aab35275d72bfa2788128c197c5af1e4a820c8737ef4" +checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] [[package]] -name = "x448" -version = "0.6.0" +name = "x25519-dalek" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "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", + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", ] [[package]] @@ -4850,25 +5418,27 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec 0.9.1", "time", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4883,35 +5453,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -4924,35 +5494,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4961,9 +5531,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4972,13 +5542,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -4995,13 +5565,13 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap", "lzma-rust2", "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.10.7", "time", "zeroize", "zopfli", @@ -5010,15 +5580,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index 8767b56..b56f6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,56 +1,26 @@ -[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", +[workspace] +members = [ + "iota-storage", + "iota-connection", "client", - "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" + "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" diff --git a/LICENSE b/LICENSE index d218eff..e952c84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,16 +1,15 @@ -Copyright (c) [2025] [Methanium] +Copyright (c) 2025 Methanium + All rights reserved. -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. +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. -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. +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. diff --git a/README.md b/README.md index f5f05a7..5a8c16a 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,65 @@ 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 new file mode 100644 index 0000000..8759d48 --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,14 @@ +[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 new file mode 100644 index 0000000..a43c308 --- /dev/null +++ b/client/src/client_connection.rs @@ -0,0 +1,497 @@ +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/src/gui/tui.rs b/client/src/client_connection_manager.rs similarity index 100% rename from src/gui/tui.rs rename to client/src/client_connection_manager.rs diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..f3e6b6c --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,2 @@ +mod client_connection; +pub use client_connection::ClientConnection; diff --git a/communities/Cargo.lock b/communities/Cargo.lock new file mode 100644 index 0000000..dec0ac6 --- /dev/null +++ b/communities/Cargo.lock @@ -0,0 +1,5236 @@ +# 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 new file mode 100644 index 0000000..36ef42e --- /dev/null +++ b/communities/Cargo.toml @@ -0,0 +1,16 @@ +[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/src/communities/community.rs b/communities/src/community.rs similarity index 90% rename from src/communities/community.rs rename to communities/src/community.rs index 835c88a..7b23247 100644 --- a/src/communities/community.rs +++ b/communities/src/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_bytes(&buf).unwrap(); + let private_key = Secret::from(buf); 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_bytes(&buf).unwrap(); + let private_key = Secret::from(buf); 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_bytes(self.private_key.as_bytes()).unwrap() + Secret::from(*self.private_key.as_bytes()) } 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::error); + return CommunicationValue::new(CommunicationType::ErrorInternal); } 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,21 +222,23 @@ impl Community { for interactable in target_interactables.iter() { if interactable.get_name() == name { if interactable.get_codec() == "category" { - let category: &Category = - interactable.as_any().downcast_ref::().unwrap(); + let Some(category) = interactable.as_any().downcast_ref::() else { + return CommunicationValue::new(CommunicationType::ErrorInternal); + }; // 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()) - .unwrap() + .ok_or(CommunicationValue::new(CommunicationType::ErrorInternal)) + .unwrap_or_else(|error| return error) .run_function(cv.clone()) .await; } else { - return CommunicationValue::new(CommunicationType::error); + return CommunicationValue::new(CommunicationType::ErrorInternal); } } } } - CommunicationValue::new(CommunicationType::add_conversation) + CommunicationValue::new(CommunicationType::AddConversation) } pub async fn save(&self) { @@ -267,7 +269,7 @@ impl Community { let mut data = JsonValue::new_object(); let mut permissions = JsonValue::new_array(); - for perm in self.permissions.get(user).unwrap() { + for perm in self.permissions.get(user).into_iter().flatten() { if let Ok(_) = permissions.push(perm.to_string()) {} } @@ -287,10 +289,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).unwrap(); + let json_content = json::parse(&file_contents).ok()?; let user_data = file_util::load_file(&format!("communities/{}/", name), "users.json"); - let user_json: JsonValue = json::parse(&user_data).unwrap(); + let user_json: JsonValue = json::parse(&user_data).ok()?; let mut users = Vec::new(); let mut permissions: HashMap> = HashMap::new(); @@ -318,25 +320,17 @@ pub async fn load(name: &String) -> Option> { }; let community = Community { - name: json_content["name"].as_str().unwrap().to_string(), + name: json_content["name"].as_str()?.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().unwrap()) - .unwrap(), - ) - .unwrap(), - public_key: PublicKey::from( - &Secret::from_bytes( - &STANDARD - .decode(json_content["private_key"].as_str().unwrap()) - .unwrap(), - ) - .unwrap(), - ), + &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()?, + )?), interactables: Arc::new(RwLock::new(Vec::new())), connections: Arc::new(RwLock::new(HashMap::new())), }; @@ -346,7 +340,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 name = file.split('.').next().unwrap().to_string(); + let Some(name) = file.split('.').next().map(str::to_string) else { continue }; let interactable: Box = registry::load(comarc.clone(), String::new(), name).await; comarc.add_interactable(Arc::new(interactable)).await; diff --git a/src/communities/community_connection.rs b/communities/src/community_connection.rs similarity index 74% rename from src/communities/community_connection.rs rename to communities/src/community_connection.rs index 62df251..ff0aff2 100644 --- a/src/communities/community_connection.rs +++ b/communities/src/community_connection.rs @@ -21,6 +21,20 @@ 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>>>>, @@ -30,7 +44,6 @@ pub struct CommunityConnection { challenged: Arc>, challenge: Arc>, auth: Arc>>, - pub ping: Arc>, } impl CommunityConnection { pub fn new( @@ -47,13 +60,14 @@ 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())); - sender.send(message_text).await.unwrap(); // Send the message via the SplitSink + if let Err(error) = sender.send(message_text).await { + log::error!("failed to send community message: {error}"); + } } pub async fn get_community(&self) -> Option> { self.community.read().await.clone() @@ -70,12 +84,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::challenge_response) && !self.is_identified().await { + if cv.is_type(CommunicationType::ChallengeResponse) && !self.is_identified().await { self.handle_challenge_response(cv).await; return; } @@ -84,30 +98,26 @@ impl CommunityConnection { return; } - if cv.is_type(CommunicationType::ping) { - self.handle_ping(cv).await; - return; - } - - if cv.is_type(CommunicationType::client_changed) { + if cv.is_type(CommunicationType::ClientChanged) { //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 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 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 result = self .get_community() .await - .unwrap() + .ok_or(()) + .unwrap_or_else(|_| return) .run_function(self.get_user_id().await, name, path, function, &cv) .await; @@ -115,13 +125,13 @@ impl CommunityConnection { } async fn handle_identification(&self, cv: CommunicationValue) { let user_id = cv - .get_data(DataTypes::user_id) + .get_data(DataType::UserId) .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.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; }; @@ -151,7 +161,7 @@ impl CommunityConnection { let user_public_key_bytes = match STANDARD.decode(&user.public_key) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } @@ -160,14 +170,14 @@ impl CommunityConnection { let user_pub_key: PublicKey = match PublicKey::from_bytes(&user_public_key_bytes) { Some(key) => key, __ => { - self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -178,7 +188,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -200,7 +210,7 @@ impl CommunityConnection { let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) { Ok(data) => data, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -209,21 +219,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( - DataTypes::public_key, + DataType::PublicKey, STANDARD.encode(community_public_key.as_bytes()), ) - .add_data_str(DataTypes::challenge, STANDARD.encode(&encrypted_out)) - .with_id(cv.get_id()); + .add_data_str(DataType::Challenge, STANDARD.encode(&encrypted_out)) + .with_request_id(&cv); self.send_message(&response).await; } async fn handle_challenge_response(self: Arc, cv: CommunicationValue) { - let client_challenge_response_b64 = match cv.get_data(DataTypes::challenge) { + let client_challenge_response_b64 = match cv.get_data(DataType::Challenge) { Some(data) => data.to_string(), _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } @@ -232,38 +242,38 @@ impl CommunityConnection { let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) { Ok(bytes) => bytes, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } }; if challenge_response_bytes.len() < 12 { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } let Some(user) = self.auth.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; }; let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidPublicKey) .await; return; }; let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -273,7 +283,7 @@ impl CommunityConnection { let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) { Some(secret) => secret, _ => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; } @@ -297,7 +307,7 @@ impl CommunityConnection { let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) { Ok(pt) => pt, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) .await; return; } @@ -306,7 +316,7 @@ impl CommunityConnection { let client_response = match String::from_utf8(decrypted_bytes) { Ok(str) => str, Err(_) => { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidData) .await; return; } @@ -315,7 +325,7 @@ impl CommunityConnection { let expected_challenge = self.challenge.read().await.clone(); if client_response != expected_challenge { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge) .await; self.close().await; return; @@ -327,7 +337,7 @@ impl CommunityConnection { } let Some(community) = self.community.read().await.clone() else { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInternal) .await; return; }; @@ -335,15 +345,15 @@ impl CommunityConnection { let user_id = self.get_user_id().await; if user_id == 0 { - self.send_error_response(&cv.get_id(), CommunicationType::error) + self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId) .await; return; } arc.add_connection(self.clone()).await; - let response = CommunicationValue::new(CommunicationType::identification_response) - .add_data(DataTypes::interactables, { + let response = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_data(DataType::Interactables, { let a: Vec>> = arc.get_interactables(user_id).await; let mut c: JsonValue = JsonValue::new_object(); for b in a { @@ -354,13 +364,17 @@ impl CommunityConnection { } c }) - .with_id(cv.get_id()); + .with_request_id(&cv); self.send_message(&response).await; } - async fn send_error_response(&self, message_id: &Uuid, error_type: CommunicationType) { - let error = CommunicationValue::new(error_type).with_id(*message_id); + async fn send_error_response( + &self, + request: &CommunicationValue, + error_type: CommunicationType, + ) { + let error = CommunicationValue::new(error_type).with_request_id(request); self.send_message(&error).await; } pub async fn close(&self) { @@ -370,27 +384,11 @@ impl CommunityConnection { pub async fn handle_close(self: Arc) { if self.is_identified().await { if self.get_user_id().await != 0 { - self.community - .read() - .await - .as_ref() - .unwrap() - .remove_connection(self.clone()) - .await; + if let Some(community) = self.community.read().await.as_ref() { + community.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/src/communities/community_manager.rs b/communities/src/community_manager.rs similarity index 100% rename from src/communities/community_manager.rs rename to communities/src/community_manager.rs diff --git a/src/communities/interactables/category.rs b/communities/src/interactables/category.rs similarity index 89% rename from src/communities/interactables/category.rs rename to communities/src/interactables/category.rs index 6b6415f..2896e8c 100644 --- a/src/communities/interactables/category.rs +++ b/communities/src/interactables/category.rs @@ -1,121 +1,120 @@ -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; - } -} +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; + } +} diff --git a/src/communities/interactables/interactable.rs b/communities/src/interactables/interactable.rs similarity index 93% rename from src/communities/interactables/interactable.rs rename to communities/src/interactables/interactable.rs index 0521de6..dd7f326 100644 --- a/src/communities/interactables/interactable.rs +++ b/communities/src/interactables/interactable.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use json::JsonValue; use std::any::Any; use std::sync::Arc; -use ttp_core::CommunicationValue; +use mtp::codec::CommunicationValue; use uuid::Uuid; pub type InteractableFactory = fn() -> Box; diff --git a/src/communities/interactables/registry.rs b/communities/src/interactables/registry.rs similarity index 100% rename from src/communities/interactables/registry.rs rename to communities/src/interactables/registry.rs diff --git a/src/communities/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs similarity index 73% rename from src/communities/interactables/text_chat.rs rename to communities/src/interactables/text_chat.rs index 88e1545..780d8ed 100644 --- a/src/communities/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -1,261 +1,278 @@ -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; - } -} +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; + } +} diff --git a/src/communities/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs similarity index 80% rename from src/communities/interactables/voice_chat.rs rename to communities/src/interactables/voice_chat.rs index 2a2e3a2..7681765 100644 --- a/src/communities/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -1,187 +1,188 @@ -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; - } -} +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; + } +} diff --git a/src/communities/mod.rs b/communities/src/lib.rs similarity index 95% rename from src/communities/mod.rs rename to communities/src/lib.rs index ecad116..237b709 100644 --- a/src/communities/mod.rs +++ b/communities/src/lib.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/src/communities/perms/permission.rs b/communities/src/perms/permission.rs similarity index 100% rename from src/communities/perms/permission.rs rename to communities/src/perms/permission.rs diff --git a/dockerfile b/dockerfile index ee00add..fcbfafa 100644 --- a/dockerfile +++ b/dockerfile @@ -4,7 +4,7 @@ FROM rust:latest AS builder WORKDIR /app COPY . . -RUN cargo build --release +RUN cargo build --release -p iota-daemon # Runtime stage FROM debian:sid @@ -13,8 +13,10 @@ 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 . +COPY --from=builder /app/target/release/iota-daemon . + +RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota EXPOSE 1984 -CMD ["./iota"] \ No newline at end of file +CMD ["./iota-daemon"] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3042264 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "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 new file mode 100644 index 0000000..e2ca8a1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,275 @@ +{ + 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 new file mode 100644 index 0000000..f35935a --- /dev/null +++ b/iota-auth/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "iota-auth" +version = "0.1.0" +edition = "2024" + +[dependencies] +json = "*" diff --git a/src/auth/auth_user.rs b/iota-auth/src/auth_user.rs similarity index 100% rename from src/auth/auth_user.rs rename to iota-auth/src/auth_user.rs diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/iota-auth/src/lib.rs @@ -0,0 +1 @@ + diff --git a/src/auth/local_auth.rs b/iota-auth/src/local_auth.rs similarity index 91% rename from src/auth/local_auth.rs rename to iota-auth/src/local_auth.rs index 112ee2b..f9c0fd1 100644 --- a/src/auth/local_auth.rs +++ b/iota-auth/src/local_auth.rs @@ -1,6 +1,6 @@ use json::JsonValue; -use crate::util::file_util::load_file; +use iota_iota_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/iota-cli/Cargo.toml b/iota-cli/Cargo.toml new file mode 100644 index 0000000..87b3954 --- /dev/null +++ b/iota-cli/Cargo.toml @@ -0,0 +1,29 @@ +[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 new file mode 100755 index 0000000..cfb4b3b --- /dev/null +++ b/iota-cli/src/app_state.rs @@ -0,0 +1 @@ +pub use iota_state::*; diff --git a/iota-cli/src/controls/action.rs b/iota-cli/src/controls/action.rs new file mode 100644 index 0000000..4fb3c27 --- /dev/null +++ b/iota-cli/src/controls/action.rs @@ -0,0 +1,7 @@ +#[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 new file mode 100644 index 0000000..1ce27e4 --- /dev/null +++ b/iota-cli/src/controls/button.rs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..f985510 --- /dev/null +++ b/iota-cli/src/controls/checkbox_group.rs @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..713971b --- /dev/null +++ b/iota-cli/src/controls/choice.rs @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..3756641 --- /dev/null +++ b/iota-cli/src/controls/dialog.rs @@ -0,0 +1,267 @@ +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 new file mode 100644 index 0000000..c38eda4 --- /dev/null +++ b/iota-cli/src/controls/header.rs @@ -0,0 +1,164 @@ +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 new file mode 100644 index 0000000..02eaa84 --- /dev/null +++ b/iota-cli/src/controls/mod.rs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..0f292c5 --- /dev/null +++ b/iota-cli/src/controls/navigation.rs @@ -0,0 +1,6 @@ +#[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 new file mode 100644 index 0000000..7307a03 --- /dev/null +++ b/iota-cli/src/controls/panel.rs @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..9aaacb5 --- /dev/null +++ b/iota-cli/src/controls/radio_group.rs @@ -0,0 +1,194 @@ +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 new file mode 100644 index 0000000..585bf3c --- /dev/null +++ b/iota-cli/src/controls/scroll.rs @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..db861e6 --- /dev/null +++ b/iota-cli/src/elements/console_card.rs @@ -0,0 +1,519 @@ +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/src/gui/elements/elements.rs b/iota-cli/src/elements/elements.rs similarity index 87% rename from src/gui/elements/elements.rs rename to iota-cli/src/elements/elements.rs index 3429e52..1af14cc 100644 --- a/src/gui/elements/elements.rs +++ b/iota-cli/src/elements/elements.rs @@ -3,14 +3,16 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::Borders}; -use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{ + interaction_result::InteractionResult, render_context::RenderContext, 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); + fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>); } #[allow(unused)] diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs new file mode 100644 index 0000000..f47aab6 --- /dev/null +++ b/iota-cli/src/elements/graph_card.rs @@ -0,0 +1,287 @@ +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/src/gui/elements/log_card.rs b/iota-cli/src/elements/log_card.rs similarity index 59% rename from src/gui/elements/log_card.rs rename to iota-cli/src/elements/log_card.rs index e2d4ba1..a7d72eb 100755 --- a/src/gui/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,96 +1,110 @@ -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 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 ratatui::{ Frame, layout::Rect, - style::{Color, Style}, + style::Style, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::any::Any; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + any::Any, + sync::atomic::{AtomicUsize, Ordering}, +}; +use unicode_width::UnicodeWidthChar; -#[derive(Clone, Debug)] -pub struct UiLogEntry { - pub timestamp_ms: u128, - pub sender: PrintType, - pub message: String, - pub is_error: bool, +#[derive(Clone, Copy)] +enum LogSource { + Call, + Client, + Iota, + Omikron, + Omega, + Command, + 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, +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, } } -} -#[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, + 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, } } } pub struct LogCard { + state: ClientState, focused: bool, selected: bool, scroll_offset: usize, - last_total_lines: usize, - last_visible_height: usize, + last_total_lines: AtomicUsize, + last_visible_height: AtomicUsize, + last_width: AtomicUsize, + filter: String, + filtering: bool, pub borders: Borders, pub joins: Borders, } impl LogCard { - pub fn new() -> Self { + pub fn new(state: ClientState) -> Self { Self { + state, focused: false, selected: false, scroll_offset: 0, - last_total_lines: 0, - last_visible_height: 10, + last_total_lines: AtomicUsize::new(0), + last_visible_height: AtomicUsize::new(1), + last_width: AtomicUsize::new(1), + filter: String::new(), + filtering: false, borders: Borders::ALL, joins: Borders::NONE, } } fn get_logs(&self) -> Vec { - let state = APP_STATE.lock().unwrap(); - state.get_logs().iter().cloned().collect() + 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() } fn find_split_point(s: &str, max_width: usize) -> usize { @@ -102,7 +116,7 @@ impl LogCard { let mut last_boundary = 0usize; for (idx, ch) in s.char_indices() { - let char_width = if ch.is_ascii() { 1 } else { 2 }; + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); if current_width + char_width > max_width { if last_boundary == 0 { return idx + ch.len_utf8(); @@ -116,7 +130,7 @@ impl LogCard { s.len() } - fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> { + fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> { let mut result = Vec::new(); let timestamp = entry.format_timestamp(); @@ -179,7 +193,7 @@ impl LogCard { line.push_str(×tamp); } - result.push((line, entry.sender.prefix_color(), entry.is_error)); + result.push((line, LogSource::from_sender(&entry.sender), entry.is_error)); } result @@ -189,7 +203,7 @@ impl LogCard { &self, entries: Vec, width: usize, - ) -> Vec<(String, Color, bool)> { + ) -> Vec<(String, LogSource, bool)> { let mut lines = Vec::new(); for entry in entries { @@ -215,11 +229,13 @@ impl LogCard { } fn get_title_hints(&self) -> (bool, bool) { - if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { + 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 { return (false, false); } - let max_offset = self.last_total_lines - self.last_visible_height; + let max_offset = total_lines - visible_height; let can_scroll_up = self.scroll_offset < max_offset; let can_scroll_down = self.scroll_offset > 0; @@ -227,6 +243,12 @@ 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(); } @@ -251,7 +273,8 @@ impl LogCard { fn scroll_up(&mut self) { let max_offset = self .last_total_lines - .saturating_sub(self.last_visible_height); + .load(Ordering::Relaxed) + .saturating_sub(self.last_visible_height.load(Ordering::Relaxed)); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } @@ -295,61 +318,92 @@ impl Element for LogCard { self } - fn render(&self, f: &mut Frame, area: Rect) { + fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { let entries = self.get_logs(); - 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); + 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 + }; if inner_area.width == 0 || inner_area.height == 0 { - draw_block_joins(f, area, self.borders, self.joins); + draw_block_joins( + f, + area, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); 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, prefix_color, is_error)| { + .map(|(line, source, 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(), - Style::default().fg(*prefix_color), + source.style(context.theme), )); } let (content, timestamp) = Self::split_timestamp_suffix(rest); - let text_color = if *is_error { Color::Red } else { Color::White }; + let text_style = if *is_error { + context.theme.logs.error + } else { + context.theme.logs.text + }; if !content.is_empty() { - spans.push(Span::styled( - content.to_string(), - Style::default().fg(text_color), - )); + spans.push(Span::styled(content.to_string(), text_style)); } if !timestamp.is_empty() { spans.push(Span::styled( timestamp.to_string(), - Style::default().fg(Color::DarkGray), + context.theme.logs.timestamp, )); } @@ -367,7 +421,19 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - draw_block_joins(f, area, self.borders, self.joins); + 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 + }, + ); + } } } @@ -415,14 +481,44 @@ 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 estimated_width = 80usize; - let all_lines = self.build_all_lines(entries, estimated_width); + let width = self.last_width.load(Ordering::Relaxed).max(1); + let all_lines = self.build_all_lines(entries, width); - self.last_total_lines = all_lines.len(); - let visible_height = self.last_visible_height.max(1); + 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); 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 @@ -456,8 +552,8 @@ impl InteractableElement for LogCard { InteractionResult::Handled } KeyCode::Home => { - if self.last_total_lines > visible_height { - self.scroll_offset = self.last_total_lines - visible_height; + if total_lines > visible_height { + self.scroll_offset = total_lines - visible_height; } InteractionResult::Handled } diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs new file mode 100644 index 0000000..007c223 --- /dev/null +++ b/iota-cli/src/help_overlay.rs @@ -0,0 +1,219 @@ +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 new file mode 100644 index 0000000..842f89c --- /dev/null +++ b/iota-cli/src/input_handler.rs @@ -0,0 +1,61 @@ +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/src/gui/interaction_result.rs b/iota-cli/src/interaction_result.rs similarity index 82% rename from src/gui/interaction_result.rs rename to iota-cli/src/interaction_result.rs index 462149a..8afad00 100644 --- a/src/gui/interaction_result.rs +++ b/iota-cli/src/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::gui::screens::screens::Screen; +use crate::screens::screens::{Screen, UiEvent}; #[allow(unused)] pub enum InteractionResult { @@ -13,6 +13,9 @@ pub enum InteractionResult { OpenFutureScreen { screen: Pin> + Send>>, }, + AppTask { + task: Pin + Send>>, + }, Handled, Unhandled, } @@ -22,6 +25,7 @@ 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"), @@ -36,6 +40,9 @@ 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/ipc_client.rs b/iota-cli/src/ipc_client.rs new file mode 100644 index 0000000..45ca8fc --- /dev/null +++ b/iota-cli/src/ipc_client.rs @@ -0,0 +1,845 @@ +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 new file mode 100644 index 0000000..3908272 --- /dev/null +++ b/iota-cli/src/layout/fit.rs @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..f629920 --- /dev/null +++ b/iota-cli/src/layout/mod.rs @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..0ba471e --- /dev/null +++ b/iota-cli/src/layout/text_measure.rs @@ -0,0 +1,10 @@ +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/src/gui/mod.rs b/iota-cli/src/lib.rs similarity index 54% rename from src/gui/mod.rs rename to iota-cli/src/lib.rs index b355046..bd2e44d 100644 --- a/src/gui/mod.rs +++ b/iota-cli/src/lib.rs @@ -5,16 +5,31 @@ 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/iota-cli/src/notification.rs b/iota-cli/src/notification.rs new file mode 100644 index 0000000..0fd3cb7 --- /dev/null +++ b/iota-cli/src/notification.rs @@ -0,0 +1,128 @@ +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 new file mode 100644 index 0000000..8b7d3c9 --- /dev/null +++ b/iota-cli/src/render_context.rs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..b9cae99 --- /dev/null +++ b/iota-cli/src/screens/daemon_setup.rs @@ -0,0 +1,302 @@ +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 new file mode 100644 index 0000000..53e68b3 --- /dev/null +++ b/iota-cli/src/screens/main_screen.rs @@ -0,0 +1,515 @@ +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/src/gui/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs similarity index 80% rename from src/gui/screens/md_viewer.rs rename to iota-cli/src/screens/md_viewer.rs index 10eabce..8c46d3d 100644 --- a/src/gui/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use crossterm::event::{self, Event, KeyCode}; use ratatui::{ DefaultTerminal, prelude::*, @@ -7,11 +7,16 @@ use ratatui::{ }; use std::{any::Any, time::Duration}; -use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen}; +use crate::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{HitMap, Screen, UiEvent}, + theme::{ResolvedTheme, TextSemantics, ThemeName}, +}; pub struct FileViewer { title: String, - text: Vec, + content: String, scroll: u16, scroll_x: u16, } @@ -24,11 +29,14 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect) { - self.draw(f, rect); + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + self.draw(f, rect, context.theme); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; @@ -52,7 +60,7 @@ impl FileViewer { pub fn new(title: String, content: &str) -> Self { Self { title, - text: parse_document(content.to_owned()), + content: content.to_owned(), scroll: 0, scroll_x: 0, } @@ -62,7 +70,7 @@ impl FileViewer { terminal .draw(|f| { let area = f.area(); - self.draw(f, area); + self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi)); }) .unwrap(); @@ -77,12 +85,13 @@ impl FileViewer { } terminal } - fn draw(&self, f: &mut Frame, area: Rect) { + fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) { use ratatui::text::Text; let mut rendered_lines = Vec::new(); + let text = parse_document(&self.content, theme); - for display_line in &self.text { + for display_line in &text { if display_line.scrollable { let content: String = display_line .line @@ -153,7 +162,7 @@ impl FileViewer { } } } -fn parse_document(input: String) -> Vec { +fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { let mut lines_vec = Vec::new(); let mut in_code_block = false; let liness: Vec = input.lines().map(String::from).collect(); @@ -172,7 +181,7 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( format!("────────{}────────", code), - Style::default().fg(Color::DarkGray), + theme.markdown.divider, )), scrollable: false, }); @@ -182,10 +191,7 @@ fn parse_document(input: String) -> Vec { if in_code_block { lines_vec.push(DisplayLine { - line: Line::from(Span::styled( - raw.to_string(), - Style::default().fg(Color::Yellow), - )), + line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)), scrollable: false, }); i += 1; @@ -195,9 +201,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("### ").to_string(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -208,9 +218,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("## ").to_string(), - Style::default() - .fg(Color::LightCyan) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -221,9 +235,13 @@ fn parse_document(input: String) -> Vec { lines_vec.push(DisplayLine { line: Line::from(Span::styled( raw.trim_start_matches("# ").to_string(), - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD), + theme.apply_text_semantics( + theme.markdown.heading, + TextSemantics { + bold: true, + underline: false, + }, + ), )), scrollable: false, }); @@ -254,13 +272,13 @@ fn parse_document(input: String) -> Vec { } let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::>()); - lines_vec.extend(table_to_lines(table)); + lines_vec.extend(table_to_lines(table, theme)); i = j; continue; } lines_vec.push(DisplayLine { - line: Line::from(parse_inline(raw.as_str())), + line: Line::from(parse_inline(raw.as_str(), theme)), scrollable: false, }); i += 1; @@ -269,7 +287,7 @@ fn parse_document(input: String) -> Vec { lines_vec } -fn parse_inline(input: &str) -> Vec> { +fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { let mut spans = Vec::new(); let mut buf = String::new(); @@ -294,7 +312,11 @@ fn parse_inline(input: &str) -> Vec> { }; if let Some(kind) = toggle { - flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + flush_span( + &mut spans, + &mut buf, + current_style(bold, underline, code, theme), + ); match kind { "bold" => bold = !bold, @@ -308,24 +330,21 @@ fn parse_inline(input: &str) -> Vec> { buf.push(c); } - flush_span(&mut spans, &mut buf, current_style(bold, underline, code)); + flush_span( + &mut spans, + &mut buf, + current_style(bold, underline, code, theme), + ); spans } -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 +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 }) } #[derive(Clone)] pub struct DisplayLine { @@ -333,7 +352,7 @@ pub struct DisplayLine { scrollable: bool, } -fn table_to_lines(table: Vec>) -> Vec { +fn table_to_lines(table: Vec>, theme: &ResolvedTheme) -> Vec { if table.len() < 2 { return vec![]; } @@ -377,7 +396,7 @@ fn table_to_lines(table: Vec>) -> Vec { .join("─┼─"); lines.push(DisplayLine { - line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))), + line: Line::from(Span::styled(divider, theme.markdown.divider)), scrollable: true, }); continue; @@ -403,11 +422,15 @@ fn table_to_lines(table: Vec>) -> Vec { } let style = if row_idx == 0 { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + theme.apply_text_semantics( + theme.markdown.table_header, + TextSemantics { + bold: true, + underline: false, + }, + ) } else { - Style::default().fg(Color::Green) + theme.markdown.table_text }; lines.push(DisplayLine { diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs new file mode 100644 index 0000000..1243979 --- /dev/null +++ b/iota-cli/src/screens/metrics.rs @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..6f32393 --- /dev/null +++ b/iota-cli/src/screens/overview.rs @@ -0,0 +1,302 @@ +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 new file mode 100644 index 0000000..5299b3b --- /dev/null +++ b/iota-cli/src/screens/screens.rs @@ -0,0 +1,143 @@ +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 new file mode 100644 index 0000000..a0a37d8 --- /dev/null +++ b/iota-cli/src/screens/settings.rs @@ -0,0 +1,475 @@ +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/src/gui/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs similarity index 72% rename from src/gui/screens/terms_checker.rs rename to iota-cli/src/screens/terms_checker.rs index 26bb7c6..885bd65 100644 --- a/src/gui/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -1,29 +1,32 @@ use crate::{ - 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}, + controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::{ + md_viewer::FileViewer, + screens::{HitMap, Screen, UiEvent}, }, + util::{buttons::draw_buttons, terms_focus::Focus}, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; +use iota_terms::{TermsType, get_link, get_terms}; 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, sync::Arc}; +use std::{any::Any, pin::Pin}; 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, @@ -34,9 +37,8 @@ pub struct TermsCheckerScreen { } impl TermsCheckerScreen { - pub fn new(ui: Arc, sender: Option>) -> Self { + pub fn new(sender: Option>) -> Self { Self { - ui, sender, eula: false, tos: false, @@ -54,7 +56,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -92,19 +94,19 @@ impl Screen for TermsCheckerScreen { height: content_height, }); let eula_text = if size.width < 70 { - "EULA ¹ (https://legal.tensamin.net/eula/)" + "EULA ¹ (https://legal.methanium.net/tensamin/eula)" } else { - "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)" + "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)" }; let tos_text = if size.width < 72 { - "ToS ² (https://legal.tensamin.net/terms-of-service/)" + "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)" } else { - "Terms of Service ² (https://legal.tensamin.net/terms-of-service/)" + "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)" }; let pp_text = if size.width < 68 { - "PP ² (https://legal.tensamin.net/privacy-policy/)" + "PP ² (https://legal.methanium.net/tensamin/privacy-policy)" } else { - "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)" + "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)" }; let (mut optional_lines, agree_lines): (Vec, Vec<&str>) = if size.width > 143 { @@ -164,9 +166,36 @@ impl Screen for TermsCheckerScreen { ) }; let mut text_lines = vec![ - 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), + 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, + ), Line::from(""), Line::from("¹ Necessary– required to run the program"), Line::from("² Optional – required only for Tensamin services"), @@ -186,19 +215,19 @@ impl Screen for TermsCheckerScreen { if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.width >= 60 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let height_style = if size.height > 19 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.height >= 13 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let warning_text = Text::from(vec![ @@ -240,10 +269,14 @@ impl Screen for TermsCheckerScreen { true, false, true, + context.theme, ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { @@ -276,19 +309,27 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(Type::EULA), - Focus::Tos => Some(Type::TOS), - Focus::Pp => Some(Type::PP), + Focus::Eula => Some(TermsType::EULA), + Focus::Tos => Some(TermsType::TOS), + Focus::Pp => Some(TermsType::PP), _ => None, }; if let Some(terms_type) = terms_type { - 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 - }); + 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 + } + }, + ); InteractionResult::OpenFutureScreen { screen: fut } } else { InteractionResult::Unhandled @@ -296,15 +337,15 @@ impl Screen for TermsCheckerScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_link(Type::EULA)); + let _ = open::that(get_link(TermsType::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_link(Type::TOS)); + let _ = open::that(get_link(TermsType::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_link(Type::PP)); + let _ = open::that(get_link(TermsType::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/src/gui/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs similarity index 87% rename from src/gui/screens/terms_updater.rs rename to iota-cli/src/screens/terms_updater.rs index 35da364..21e3247 100644 --- a/src/gui/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -1,28 +1,33 @@ +use crate::screens::terms_checker::UserChoice; use crate::{ - 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}, + controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::{ + md_viewer::FileViewer, + screens::{HitMap, Screen, UiEvent}, }, + util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyCode; +use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; 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>, @@ -119,7 +124,19 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect) { + 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, + ) + }; let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -184,7 +201,7 @@ impl Screen for TermsUpdaterScreen { if self.eula_future { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹³ (https://legal.tensamin.net/eula/newest/)", + "EULA ¹³ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -197,7 +214,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.tensamin.net/eula/newest/)", + "End User Licence Agreement ¹³ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -212,14 +229,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "EULA ¹ (https://legal.tensamin.net/eula/newest/)", + "EULA ¹ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, )); } else { text_lines.push(checkbox( - "End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)", + "End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)", self.eula, self.focus == Focus::Eula, true, @@ -233,7 +250,7 @@ impl Screen for TermsUpdaterScreen { if self.tos_future { if size.width < 80 { text_lines.push(checkbox( - "ToS ²³ (https://legal.tensamin.net/tos/newest/)", + "ToS ²³ (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -246,7 +263,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.tensamin.net/terms-of-service/newest/)", + "Terms of Service ²³ (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -261,14 +278,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "ToS ² (https://legal.tensamin.net/tos/newest/)", + "ToS ² (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, )); } else { text_lines.push(checkbox( - "Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)", + "Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)", self.tos, self.focus == Focus::Tos, self.eula, @@ -282,7 +299,7 @@ impl Screen for TermsUpdaterScreen { if self.pp_future { if size.width < 80 { text_lines.push(checkbox( - "PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)", + "PP ²³ (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, @@ -295,7 +312,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.tensamin.net/privacy-policy/newest/)", + "Privacy Policy ²³ (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, @@ -310,14 +327,14 @@ impl Screen for TermsUpdaterScreen { } else { if size.width < 80 { text_lines.push(checkbox( - "PP ² (https://legal.tensamin.net/privacy-policy/newest/)", + "PP ² (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, )); } else { text_lines.push(checkbox( - "Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)", + "Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)", self.pp, self.focus == Focus::Pp, self.eula, @@ -519,19 +536,19 @@ impl Screen for TermsUpdaterScreen { }; if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.width >= 60 { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let height_style = if size.height > 20 { - Style::default().fg(Color::Green) + context.theme.status.success } else if size.height >= (header_lines as u16 + 10) { - Style::default().fg(Color::Yellow) + context.theme.status.warning } else { - Style::default().fg(Color::Red) + context.theme.status.error }; let warning_text = Text::from(vec![ @@ -575,10 +592,14 @@ impl Screen for TermsUpdaterScreen { self.update_needed, downgrade_scenario, self.pp_needed || self.tos_needed, + context.theme, ); } - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; let mut possible_states = Vec::new(); if self.eula_needed { @@ -644,17 +665,23 @@ impl Screen for TermsUpdaterScreen { }, KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { - Focus::Eula => Some(Type::EULA), - Focus::Tos => Some(Type::TOS), - Focus::Pp => Some(Type::PP), + Focus::Eula => Some(TermsType::EULA), + Focus::Tos => Some(TermsType::TOS), + Focus::Pp => Some(TermsType::PP), _ => None, }; if let Some(terms_type) = terms_type { let fut = Box::pin(async move { - let content = get_terms(terms_type.clone()).await.unwrap(); - Box::new(FileViewer::new(terms_type.to_string(), &content)) - as Box + 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 + } }); return InteractionResult::OpenFutureScreen { screen: fut }; @@ -664,15 +691,15 @@ impl Screen for TermsUpdaterScreen { } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { - let _ = open::that(get_newest_link(Type::EULA)); + let _ = open::that(get_newest_link(TermsType::EULA)); InteractionResult::Handled } Focus::Tos => { - let _ = open::that(get_newest_link(Type::TOS)); + let _ = open::that(get_newest_link(TermsType::TOS)); InteractionResult::Handled } Focus::Pp => { - let _ = open::that(get_newest_link(Type::PP)); + let _ = open::that(get_newest_link(TermsType::PP)); InteractionResult::Handled } _ => InteractionResult::Unhandled, diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs new file mode 100644 index 0000000..1ff2689 --- /dev/null +++ b/iota-cli/src/screens/users.rs @@ -0,0 +1,741 @@ +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 new file mode 100644 index 0000000..26d6046 --- /dev/null +++ b/iota-cli/src/theme/config.rs @@ -0,0 +1,314 @@ +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 new file mode 100644 index 0000000..3154704 --- /dev/null +++ b/iota-cli/src/theme/mod.rs @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..5d4955e --- /dev/null +++ b/iota-cli/src/theme/model.rs @@ -0,0 +1,168 @@ +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 new file mode 100644 index 0000000..1fbfeb8 --- /dev/null +++ b/iota-cli/src/theme/name.rs @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..301339a --- /dev/null +++ b/iota-cli/src/theme/presets.rs @@ -0,0 +1,324 @@ +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/tui.rs b/iota-cli/src/tui.rs new file mode 100644 index 0000000..e69de29 diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs new file mode 100644 index 0000000..ece8697 --- /dev/null +++ b/iota-cli/src/ui.rs @@ -0,0 +1,728 @@ +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/src/gui/util/borders.rs b/iota-cli/src/util/borders.rs similarity index 74% rename from src/gui/util/borders.rs rename to iota-cli/src/util/borders.rs index 34460bc..a6d26a8 100644 --- a/src/gui/util/borders.rs +++ b/iota-cli/src/util/borders.rs @@ -3,13 +3,20 @@ use ratatui::prelude::*; use ratatui::style::Style; use ratatui::widgets::Borders; -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()); +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); } -pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) { +pub fn draw_block_joins( + frame: &mut Frame, + area: Rect, + borders: Borders, + joins: Borders, + style: Style, +) { + if area.width == 0 || area.height == 0 { + return; + } let x0 = area.x; let y0 = area.y; let x1 = area.x + area.width - 1; @@ -22,7 +29,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┬', (false, false) => '┌', }; - set_join_char(frame, x0, y0, top_left); + set_join_char(frame, x0, y0, top_left, style); } if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) { @@ -32,7 +39,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┬', (false, false) => '┐', }; - set_join_char(frame, x1, y0, top_right); + set_join_char(frame, x1, y0, top_right, style); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) { @@ -45,7 +52,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┴', (false, false) => '└', }; - set_join_char(frame, x0, y1, bottom_left); + set_join_char(frame, x0, y1, bottom_left, style); } if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) { @@ -58,6 +65,6 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: (false, true) => '┴', (false, false) => '┘', }; - set_join_char(frame, x1, y1, bottom_right); + set_join_char(frame, x1, y1, bottom_right, style); } } diff --git a/iota-cli/src/util/buttons.rs b/iota-cli/src/util/buttons.rs new file mode 100644 index 0000000..a69d2ad --- /dev/null +++ b/iota-cli/src/util/buttons.rs @@ -0,0 +1,75 @@ +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/src/terms/focus.rs b/iota-cli/src/util/terms_focus.rs similarity index 100% rename from src/terms/focus.rs rename to iota-cli/src/util/terms_focus.rs diff --git a/iota-cli/tests/button_layout.rs b/iota-cli/tests/button_layout.rs new file mode 100644 index 0000000..ad230fd --- /dev/null +++ b/iota-cli/tests/button_layout.rs @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..a9ef9d3 --- /dev/null +++ b/iota-cli/tests/choice_rendering.rs @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..3089bdb --- /dev/null +++ b/iota-cli/tests/control_state.rs @@ -0,0 +1,109 @@ +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 new file mode 100644 index 0000000..f8a60aa --- /dev/null +++ b/iota-cli/tests/layout_fit.rs @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..666bffe --- /dev/null +++ b/iota-cli/tests/settings_snapshot.rs @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..0148648 --- /dev/null +++ b/iota-connection/Cargo.toml @@ -0,0 +1,12 @@ +[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 new file mode 100644 index 0000000..6a20bb1 --- /dev/null +++ b/iota-connection/src/connection_handler.rs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..db303ab --- /dev/null +++ b/iota-connection/src/lib.rs @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..742d9b6 --- /dev/null +++ b/iota-connection/src/message_common.rs @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..7da9a1d --- /dev/null +++ b/iota-connection/src/message_handlers.rs @@ -0,0 +1,1784 @@ +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 mtp::codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent, +}; + +use crate::relay::VerifiedRelayContext; +use iota_storage::storage_error::StorageError; + +#[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, +} + +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())?; + 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(()), + } +} + +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 full = !cache_valid + || reported_version == 0 + || !known_session + || 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(); + let contact_ids = match current_contact_ids(user_id) { + Ok(contact_ids) => contact_ids, + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + 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::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())) +} + +pub fn handle_client_state_ack(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 version = match data_i64(cv, DataType::VersionNumber) { + Some(version) if version >= 0 => version, + _ => return sync_error(cv), + }; + if sync::acknowledge(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 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), + } +} + +#[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 new file mode 100644 index 0000000..8dde6c3 --- /dev/null +++ b/iota-connection/src/relay.rs @@ -0,0 +1,383 @@ +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 new file mode 100644 index 0000000..f28cc45 --- /dev/null +++ b/iota-core/Cargo.toml @@ -0,0 +1,18 @@ +[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/src/terms/consent_state.rs b/iota-core/src/consent_state.rs similarity index 87% rename from src/terms/consent_state.rs rename to iota-core/src/consent_state.rs index 1cc6e0a..556ebe5 100644 --- a/src/terms/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -1,71 +1,79 @@ -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}; -pub async fn check(ui: Arc) -> (bool, bool) { +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> { let mut state = ConsentState::load_state(); - 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); - }; + 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?; state = state.sanitize(); state.save_state(); - (state.accepted_eula, state.accepted_tos && state.accepted_pp) + Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp)) } -async fn ensure_initial_consent(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { - if state.accepted_eula { +#[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 { 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(ui.clone(), Some(tx)))) + ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx)))) .await; let result = rx.await.unwrap_or(UserChoice::Deny); match result { UserChoice::AcceptEULA | UserChoice::AcceptAll => { - if let Some((eula, tos, privacy)) = get_current_docs().await { - state.accepted_eula = true; - state.eula = Some(eula); + state.accepted_eula = true; + state.eula = Some(current_eula); - if matches!(result, UserChoice::AcceptAll) { - state.accepted_tos = true; - state.accepted_pp = true; - state.tos = Some(tos); - state.privacy = Some(privacy); - } + if matches!(result, UserChoice::AcceptAll) { + state.accepted_tos = true; + state.accepted_pp = true; + state.tos = Some(current_tos); + state.privacy = Some(current_privacy); } let _ = &state.save_state(); Ok(()) } - UserChoice::Deny => Err(()), + UserChoice::Deny => Ok(()), } } -async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> { +/* +async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { return Ok(()); }; @@ -96,7 +104,9 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> UserChoice::AcceptEULA => { state.accepted_eula = true; } - UserChoice::Deny => return Err(()), + UserChoice::Deny => { + return Err("Consent update was denied for a mandatory document.".to_string()); + } } } else { apply_future_updates(state, result, eula_update, tos_update, privacy_update); @@ -105,6 +115,8 @@ async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), ()> state.save_state(); Ok(()) } +*/ +/* fn apply_future_updates( state: &mut ConsentState, result: UserChoice, @@ -215,20 +227,7 @@ 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 { @@ -303,7 +302,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.tensamin.net/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.methanium.net/tensamin/eula\ \nEULA={}\ \nEULA-VERSION={}\ \nEULA-HASH={}\ @@ -313,7 +312,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.tensamin.net/tos/\ + \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\ \nTerms-of-Service={}\ \nTerms-of-Service-VERSION={}\ \nTerms-of-Service-HASH={}\ @@ -323,7 +322,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.tensamin.net/privacy/\ + \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\ \nPrivacy-Policy={}\ \nPrivacy-Policy-VERSION={}\ \nPrivacy-Policy-HASH={}\ @@ -331,7 +330,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.tensamin.net/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.methanium.net/tensamin/eula\ \nEULA=false\ "); } diff --git a/iota-core/src/lib.rs b/iota-core/src/lib.rs new file mode 100644 index 0000000..2deef45 --- /dev/null +++ b/iota-core/src/lib.rs @@ -0,0 +1 @@ +pub mod consent_state; diff --git a/src/main.rs b/iota-core/src/main.rs similarity index 52% rename from src/main.rs rename to iota-core/src/main.rs index 5df39b6..22a942f 100644 --- a/src/main.rs +++ b/iota-core/src/main.rs @@ -1,55 +1,62 @@ -use dashmap::DashSet; -use once_cell::sync::Lazy; +mod consent_state; +use iota_updater::check_update; 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}; -mod auth; -mod gui; -mod langu; -mod omikron; -mod terms; -mod users; -mod util; +use iota_state::{AppState, DaemonState}; -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()); +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; #[tokio::main(flavor = "multi_thread", worker_threads = 16)] -#[allow(unused_must_use, dead_code)] +#[allow(unused_must_use, dead_code, unused_assignments)] async fn main() { - while *RELOAD.read().await { - *RELOAD.write().await = false; - *SHUTDOWN.write().await = false; + let state = Arc::new(DaemonState::new()); - let ui = start_tui(); + while *state.reload.read().await { + *state.reload.write().await = false; + *state.shutdown.write().await = false; - let (eula, tos_pp) = consent_state::check(ui.clone()).await; + 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; + } + }; if !eula { - *SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -59,9 +66,9 @@ async fn main() { return; } if !tos_pp { - *SHUTDOWN.write().await = true; + *state.shutdown.write().await = true; loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_millis(100)).await; @@ -73,7 +80,8 @@ async fn main() { println!("You can find this at 'agreements'!"); return; } - app_state::setup(); + check_update(); + iota_state::setup(&state); let main_screen = MainScreen::new(ui.clone()).await; ui.set_screen(Box::new(main_screen)).await; @@ -88,12 +96,15 @@ async fn main() { logger::startup(); // BASIC CONFIGURATION - &CONFIG.write().await.load(); + iota_storage::util::config_util::load_config(); // USER MANAGEMENT - if let Err(_) = user_manager::load_users().await { + if let Err(_) = user_manager::load_users_sync() { 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(); @@ -108,7 +119,11 @@ async fn main() { } log!( "IOTA ID: {}", - CONFIG.read().await.get_iota_id().to_string() + CONFIG + .load() + .iota_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "N/A".to_string()) ); log!("User IDS: {}", sb); @@ -127,29 +142,18 @@ async fn main() { sb1 = sb1 + ","; } log!("Community IDS: {}", sb1); */ - let port = CONFIG.read().await.get_port(); - let mut ip = "0.0.0.0".to_string(); + let port = CONFIG.load().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", @@ -157,30 +161,30 @@ async fn main() { ) .await; } - let _ = omikron::omikron_connection::get_omikron_connection().await; - + if !web_server::start(port).await { + log!("Failed to start the MTP web server on port {}", port); + } log_t!("setup_completed"); loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { break; } - sleep(Duration::from_millis(100)).await; + sleep(Duration::from_millis(500)).await; } - if *RELOAD.read().await { + if *state.reload.read().await { loop { - if ACTIVE_TASKS.is_empty() { + if state.active_tasks.is_empty() { break; } sleep(Duration::from_secs(1)).await; } - &CONFIG.write().await.clear(); + iota_storage::util::config_util::clear_config(); user_manager::clear(); // Commhnities have not been implemented yet. /*community_manager::clear();*/ - *APP_STATE.lock().unwrap() = AppState::new(); + *state.app.lock().unwrap() = AppState::new(); } - ui.terminal.lock().unwrap().clear(); - ui.terminal.lock().unwrap().flush(); + let _ = session.shutdown().await; } } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml new file mode 100644 index 0000000..c9dc6eb --- /dev/null +++ b/iota-daemon-lib/Cargo.toml @@ -0,0 +1,25 @@ +[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 new file mode 100644 index 0000000..2e3c3e0 --- /dev/null +++ b/iota-daemon-lib/src/command_router.rs @@ -0,0 +1,590 @@ +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(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 new file mode 100644 index 0000000..634cbe2 --- /dev/null +++ b/iota-daemon-lib/src/daemon_state.rs @@ -0,0 +1,305 @@ +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 new file mode 100644 index 0000000..c7a5cde --- /dev/null +++ b/iota-daemon-lib/src/deployment.rs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..a472348 --- /dev/null +++ b/iota-daemon-lib/src/ipc_server.rs @@ -0,0 +1,745 @@ +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 new file mode 100644 index 0000000..3afeff8 --- /dev/null +++ b/iota-daemon-lib/src/lib.rs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..9bb1d05 --- /dev/null +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..bb2cdc6 --- /dev/null +++ b/iota-daemon-lib/src/log_buffer.rs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..b63b574 --- /dev/null +++ b/iota-daemon-lib/src/services.rs @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..89769db --- /dev/null +++ b/iota-daemon-lib/src/task_registry.rs @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..99a2aaa --- /dev/null +++ b/iota-daemon-lib/tests/command_router.rs @@ -0,0 +1,141 @@ +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 new file mode 100644 index 0000000..a24f277 --- /dev/null +++ b/iota-daemon-lib/tests/daemon_health.rs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..7540ea4 --- /dev/null +++ b/iota-daemon-lib/tests/ipc_server.rs @@ -0,0 +1,252 @@ +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 new file mode 100644 index 0000000..d0c74d6 --- /dev/null +++ b/iota-daemon-lib/tests/shutdown.rs @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..87a5636 --- /dev/null +++ b/iota-daemon/Cargo.toml @@ -0,0 +1,16 @@ +[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 new file mode 100644 index 0000000..d2ff2c6 --- /dev/null +++ b/iota-daemon/src/main.rs @@ -0,0 +1,411 @@ +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}; +#[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)"); + + // --- 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 new file mode 100644 index 0000000..755c8f7 --- /dev/null +++ b/iota-installer/Cargo.toml @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 0000000..0de2518 --- /dev/null +++ b/iota-installer/src/lib.rs @@ -0,0 +1,183 @@ +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 new file mode 100644 index 0000000..8fb9e01 --- /dev/null +++ b/iota-ipc/Cargo.toml @@ -0,0 +1,9 @@ +[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 new file mode 100644 index 0000000..acf72b1 --- /dev/null +++ b/iota-ipc/src/lib.rs @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..4006583 --- /dev/null +++ b/iota-ipc/src/protocol.rs @@ -0,0 +1,515 @@ +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 new file mode 100644 index 0000000..c6daa2b --- /dev/null +++ b/iota-ipc/src/text_commands.rs @@ -0,0 +1,334 @@ +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 new file mode 100644 index 0000000..6a4382f --- /dev/null +++ b/iota-ipc/src/transport.rs @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..5287ecb --- /dev/null +++ b/iota-logger/Cargo.toml @@ -0,0 +1,16 @@ +[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/src/langu/language_creator.rs b/iota-logger/src/language_creator.rs similarity index 98% rename from src/langu/language_creator.rs rename to iota-logger/src/language_creator.rs index fc6d1c5..2b9f109 100644 --- a/src/langu/language_creator.rs +++ b/iota-logger/src/language_creator.rs @@ -1,4 +1,5 @@ -use crate::util::file_util::save_file; +use iota_util::file_util::save_file; + use json::{self, JsonError, JsonValue}; pub fn create_languages() -> Result<(), JsonError> { diff --git a/src/langu/language_manager.rs b/iota-logger/src/language_manager.rs similarity index 50% rename from src/langu/language_manager.rs rename to iota-logger/src/language_manager.rs index c903886..2d3bda3 100644 --- a/src/langu/language_manager.rs +++ b/iota-logger/src/language_manager.rs @@ -1,4 +1,4 @@ -use crate::util::file_util::{self}; +use iota_util::file_util::{self}; use json::parse; use once_cell::sync::Lazy; use std::collections::HashMap; @@ -59,42 +59,52 @@ impl LanguagePack { } pub fn load_language(&mut self, language: &str) { - let path = format!("languages/{}/", language); + let files = [ + "frontend.json", + "omikron.json", + "buttons.json", + "debug.json", + "general.json", + ]; - 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()); + // 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 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()); - } + let loaded = files + .iter() + .all(|file| self.load_file(&format!("languages/{language}/"), file)); - 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()); + // 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 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()); - } + 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 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()); + for (key, value) in messages.entries() { + if let Some(value) = value.as_str() { + self.language.insert(key.to_string(), value.to_string()); + } } + true } pub fn get_translation(&self, key: &str) -> String { match self.language.get(key) { diff --git a/src/util/logger.rs b/iota-logger/src/lib.rs old mode 100755 new mode 100644 similarity index 59% rename from src/util/logger.rs rename to iota-logger/src/lib.rs index a5feb02..b704910 --- a/src/util/logger.rs +++ b/iota-logger/src/lib.rs @@ -1,23 +1,21 @@ 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 crate::{ - APP_STATE, - gui::{elements::log_card::LogEntry, ui::UNIQUE}, - langu::language_manager, -}; +use iota_state::{UNIQUE, UiLogEntry}; +use tokio::sync::broadcast; +pub mod language_creator; +pub mod language_manager; static LOGGER: OnceLock> = OnceLock::new(); +static LOG_BROADCASTER: OnceLock> = OnceLock::new(); #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[allow(unused)] @@ -54,25 +52,35 @@ 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::(); - LOGGER.set(tx).expect("Logger already initialized"); + if LOGGER.set(tx).is_err() { + return; + } + let (broadcast_tx, _) = broadcast::channel(512); + let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); thread::spawn(move || { - 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"); + 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() + }); for msg in rx { let resolved_message = if let Some(key) = msg.translation_key { @@ -83,7 +91,6 @@ pub fn startup() { }; 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() @@ -91,22 +98,36 @@ pub fn startup() { format!("{} ", msg.prefix) }; - let _ = writeln!( - file, - "{} {}{}", + let line = format!( + "{} {} {}{}", fixed_box(&msg.timestamp_ms.to_string(), 13), + timestamp, prefix, - entry.message + resolved_message ); - let _ = writeln!(file, " {}", timestamp); + if let Some(file) = file.as_mut() { + let _ = writeln!(file, "{}", line); + } - let mut state = APP_STATE.lock().unwrap(); - state.push_log(entry.into()); + 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); } }); } +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; @@ -125,22 +146,6 @@ 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, @@ -186,8 +191,8 @@ pub fn log_internal(kind: PrintType, prefix: String, is_error: bool, message: St #[macro_export] macro_rules! log_t { ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), false, $key, @@ -196,8 +201,8 @@ macro_rules! log_t { }; ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), false, $key, @@ -209,8 +214,8 @@ macro_rules! log_t { #[macro_export] macro_rules! log_t_err { ($key:expr) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), true, $key, @@ -219,8 +224,8 @@ macro_rules! log_t_err { }; ($key:expr, $($arg:expr),+) => { - $crate::util::logger::log_internal_translated( - $crate::util::logger::PrintType::General, + $crate::log_internal_translated( + $crate::PrintType::General, "".to_string(), true, $key, @@ -233,8 +238,8 @@ macro_rules! log_t_err { #[macro_export] macro_rules! log_command { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::Command, + $crate::log_internal( + $crate::PrintType::Command, "".to_string(), false, format!($($arg)*) @@ -246,8 +251,8 @@ macro_rules! log_command { #[macro_export] macro_rules! log { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, "".to_string(), false, format!($($arg)*) @@ -259,8 +264,8 @@ macro_rules! log { #[macro_export] macro_rules! log_in { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, ">".to_string(), false, format!($($arg)*) @@ -272,8 +277,8 @@ macro_rules! log_in { #[macro_export] macro_rules! log_out { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, "<".to_string(), false, format!($($arg)*) @@ -285,8 +290,8 @@ macro_rules! log_out { #[macro_export] macro_rules! log_err { ($($arg:tt)*) => { - $crate::util::logger::log_internal( - $crate::util::logger::PrintType::General, + $crate::log_internal( + $crate::PrintType::General, ">>".to_string(), true, format!($($arg)*) @@ -313,46 +318,52 @@ pub fn log_cv_internal( pub fn format_cv(cv: &CommunicationValue) -> String { let mut parts = Vec::new(); - 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)); + 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 comm_type = cv.get_type().to_string(); - parts.push(format!("{}", comm_type)); + 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 data: &BTreeMap = cv.get_data_container(); - - let formated_data = - format_data_container(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect()); + 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), + ); parts.push(format!("{}", formated_data)); parts.join(": ") } -fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { +fn format_data_container(data: Vec<(DataTypeId, DataValue)>, version: Version) -> String { let parts: Vec = data .into_iter() .map(|(key, value)| { let key_str = key.to_string(); match value { - DataValue::Str(s) => format!("{}=\"{}\"", key_str, s), + DataValue::Str(s) => format!("{}=\"{}\"", key_str, abbreviate_string(&s)), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); + let inner_formatted = format_data_container(inner, version.clone()); format!("{}={{ {} }}", key_str, inner_formatted) } DataValue::Array(arr) => { - let arr_formatted = format_array(arr); + let arr_formatted = format_array(arr, version.clone()); format!("{}=[{}]", key_str, arr_formatted) } @@ -361,7 +372,7 @@ fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { DataValue::BoolTrue => format!("{}=true", key_str), DataValue::BoolFalse => format!("{}=false", key_str), - DataValue::Number(num) => format!("{}={}", key_str, num), + DataValue::SignedNumber(num) => format!("{}={}", key_str, num), _ => "".to_string(), } @@ -371,19 +382,19 @@ fn format_data_container(data: Vec<(DataTypes, DataValue)>) -> String { parts.join(", ") } -fn format_array(arr: Vec) -> String { +fn format_array(arr: Vec, version: Version) -> String { let parts: Vec = arr .into_iter() .map(|value| match value { - DataValue::Str(s) => format!("\"{}\"", s), + DataValue::Str(s) => format!("\"{}\"", abbreviate_string(&s)), DataValue::Container(inner) => { - let inner_formatted = format_data_container(inner); + let inner_formatted = format_data_container(inner, version.clone()); format!("{{ {} }}", inner_formatted) } DataValue::Array(inner_arr) => { - let formatted = format_array(inner_arr); + let formatted = format_array(inner_arr, version.clone()); format!("[{}]", formatted) } @@ -392,7 +403,7 @@ fn format_array(arr: Vec) -> String { DataValue::BoolTrue => "true".to_string(), DataValue::BoolFalse => "false".to_string(), - DataValue::Number(num) => num.to_string(), + DataValue::SignedNumber(num) => num.to_string(), _ => String::new(), }) @@ -401,32 +412,57 @@ fn format_array(arr: Vec) -> 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::util::logger::log_cv_internal("", &$cv, Some($kind)) + $crate::log_cv_internal("", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("", &$cv, None) + $crate::log_cv_internal("", &$cv, None) }; } #[macro_export] macro_rules! log_cv_in { ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, Some($kind)) + $crate::log_cv_internal("> ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("> ", &$cv, None) + $crate::log_cv_internal("> ", &$cv, None) }; } #[macro_export] macro_rules! log_cv_out { ($kind:expr, $cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, Some($kind)) + $crate::log_cv_internal("< ", &$cv, Some($kind)) }; ($cv:expr) => { - $crate::util::logger::log_cv_internal("< ", &$cv, None) + $crate::log_cv_internal("< ", &$cv, None) }; } diff --git a/iota-paths/Cargo.toml b/iota-paths/Cargo.toml new file mode 100644 index 0000000..00a1b19 --- /dev/null +++ b/iota-paths/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "iota-paths" +version = "0.1.0" +edition = "2024" diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs new file mode 100644 index 0000000..0083012 --- /dev/null +++ b/iota-paths/src/lib.rs @@ -0,0 +1,569 @@ +//! 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 new file mode 100644 index 0000000..6977698 --- /dev/null +++ b/iota-process-manager/Cargo.toml @@ -0,0 +1,12 @@ +[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 new file mode 100644 index 0000000..3512a90 --- /dev/null +++ b/iota-process-manager/src/lib.rs @@ -0,0 +1,628 @@ +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 new file mode 100644 index 0000000..4db4406 --- /dev/null +++ b/iota-state/Cargo.toml @@ -0,0 +1,15 @@ +[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 new file mode 100644 index 0000000..8f668ea --- /dev/null +++ b/iota-state/src/lib.rs @@ -0,0 +1,308 @@ +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 new file mode 100644 index 0000000..9f91c52 --- /dev/null +++ b/iota-storage/Cargo.toml @@ -0,0 +1,20 @@ +[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 new file mode 100644 index 0000000..a0e5061 --- /dev/null +++ b/iota-storage/src/lib.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..86a0c53 --- /dev/null +++ b/iota-storage/src/storage_error.rs @@ -0,0 +1,15 @@ +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("{0}")] + Other(String), +} diff --git a/iota-storage/src/users/contact.rs b/iota-storage/src/users/contact.rs new file mode 100644 index 0000000..4d2ca43 --- /dev/null +++ b/iota-storage/src/users/contact.rs @@ -0,0 +1,36 @@ +#[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/src/users/mod.rs b/iota-storage/src/users/mod.rs similarity index 64% rename from src/users/mod.rs rename to iota-storage/src/users/mod.rs index aef5a02..dad90f9 100644 --- a/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,4 +1,4 @@ -pub mod contact; -pub mod user_community_util; -pub mod user_manager; +pub mod contact; +pub mod pending_operations; +pub mod user_manager; pub mod user_profile; diff --git a/iota-storage/src/users/pending_operations.rs b/iota-storage/src/users/pending_operations.rs new file mode 100644 index 0000000..4993f17 --- /dev/null +++ b/iota-storage/src/users/pending_operations.rs @@ -0,0 +1,173 @@ +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 new file mode 100644 index 0000000..90068f4 --- /dev/null +++ b/iota-storage/src/users/user_manager.rs @@ -0,0 +1,398 @@ +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> { + db::with_db(|conn| { + let tx = conn.unchecked_transaction()?; + 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 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 trusted_apps WHERE user_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/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs similarity index 58% rename from src/users/user_profile.rs rename to iota-storage/src/users/user_profile.rs index 5d60277..f1aa2a3 100644 --- a/src/users/user_profile.rs +++ b/iota-storage/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}; -// --- UserProfile --- -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserProfile { pub user_id: i64, pub username: String, @@ -16,6 +16,7 @@ pub struct UserProfile { pub reset_token: String, pub created_at: i64, pub display_name: Option, + pub trusted_apps: std::collections::HashMap, } impl UserProfile { @@ -26,6 +27,29 @@ 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, @@ -33,28 +57,12 @@ impl UserProfile { display_name, public_key, private_key_hash, - created_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64, + created_at, 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, @@ -67,13 +75,16 @@ impl UserProfile { if let Some(d) = &self.display_name { obj["display_name"] = d.clone().into(); } - if has_file("", &format!("{}.tu", self.username.clone())) { - obj["tu"] = load_file("", &format!("{}.tu", self.username.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(); obj } - pub async fn from_json(j: &JsonValue) -> Option { + + /// Legacy JSON import - used when migrating from users.json to SQLite. + pub 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(); @@ -82,7 +93,16 @@ impl UserProfile { let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); - let up = UserProfile { + 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 { user_id, username, display_name, @@ -90,22 +110,16 @@ impl UserProfile { private_key_hash, created_at, reset_token, - }; + trusted_apps, + }) + } - // 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 from_yaml(s: &str) -> Result { + serde_yaml::from_str(s) + } - Some(up) + pub fn to_yaml(&self) -> Result { + serde_yaml::to_string(self) } #[allow(dead_code)] diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs new file mode 100644 index 0000000..051557b --- /dev/null +++ b/iota-storage/src/util/chat_files.rs @@ -0,0 +1,1101 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use crate::util::sync::{self, EntityType, Operation}; +use iota_logger::log; +use rusqlite::{OptionalExtension, 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 + 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)?, + )) + }, + )?; + + let (msg_id, old_content, sent_by_self) = 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(), + )); + } + + 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: i64 = conn.query_row( + r#" + 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], + |row| row.get(0), + )?; + + let tx = conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM message_edits WHERE message_id = ?1", + params![msg_id], + )?; + tx.execute( + "DELETE FROM reactions WHERE message_id = ?1", + params![msg_id], + )?; + tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Delete, + )?; + tx.commit()?; + 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> { + ensure_message_direction(storage_owner, external_user, message_time, true)?; + hard_delete_message(storage_owner, external_user, message_time) +} + +/* 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(), + )); + } + ensure_message_direction(storage_owner, external_user, message_time, false)?; + flag_deleted_by_external(storage_owner, external_user, message_time) +} + +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, + )?; + 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: i64 = conn.query_row( + r#" + 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], + |row| row.get(0), + )?; + + 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: i64 = tx.query_row( + r#" + 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], + |row| row.get(0), + )?; + + 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: i64 = conn.query_row( + r#" + 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], + |row| row.get(0), + )?; + + 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; + 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 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + "#, + 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, + ], + )?; + 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)) = tx + .query_row( + "SELECT id, external_user, authored_at 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)?)), + ) + .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(), + )); + } + 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 + 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 (?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 ( + 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")?; + 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", + )?; + 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 new file mode 100644 index 0000000..3eb4043 --- /dev/null +++ b/iota-storage/src/util/chats_util.rs @@ -0,0 +1,123 @@ +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/communities_util.rs b/iota-storage/src/util/communities_util.rs new file mode 100644 index 0000000..3b3be8e --- /dev/null +++ b/iota-storage/src/util/communities_util.rs @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..b3dfe08 --- /dev/null +++ b/iota-storage/src/util/config_util.rs @@ -0,0 +1,243 @@ +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 new file mode 100644 index 0000000..e8cbf6e --- /dev/null +++ b/iota-storage/src/util/db.rs @@ -0,0 +1,723 @@ +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)?; + } + + 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, 16); + 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, 16); + for table in [ + "sync_heads", + "sync_events", + "client_sync_state", + "user_residency", + "relay_replay", + "pending_relays", + "relay_inbox", + "synced_settings", + "pending_user_operations", + ] { + 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, 16); + 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/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs new file mode 100644 index 0000000..55cf8cd --- /dev/null +++ b/iota-storage/src/util/e2ee_storage.rs @@ -0,0 +1,142 @@ +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/mod.rs b/iota-storage/src/util/mod.rs new file mode 100644 index 0000000..0731077 --- /dev/null +++ b/iota-storage/src/util/mod.rs @@ -0,0 +1,11 @@ +pub mod chat_files; +pub mod chats_util; +pub mod communities_util; +pub mod config_util; +pub mod db; +pub mod e2ee_storage; +pub mod relay_queue; +pub mod relay_replay; +pub mod settings; +pub mod sync; +pub mod synced_settings; diff --git a/iota-storage/src/util/relay_queue.rs b/iota-storage/src/util/relay_queue.rs new file mode 100644 index 0000000..835ebcd --- /dev/null +++ b/iota-storage/src/util/relay_queue.rs @@ -0,0 +1,135 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use iota_util::route_target::RouteTarget; +use rusqlite::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 fn enqueue( + target: RouteTarget, + 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, + }; + db::with_db(|connection| { + connection.execute( + "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + destination_id, + target_kind, + 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 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)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + }) +} + +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 new file mode 100644 index 0000000..0c5bddc --- /dev/null +++ b/iota-storage/src/util/relay_replay.rs @@ -0,0 +1,177 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::params; + +#[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_db(|connection| { + let delivered_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + connection.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(()) + }) +} + +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_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 new file mode 100644 index 0000000..6fae52b --- /dev/null +++ b/iota-storage/src/util/settings.rs @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..8ef8eb2 --- /dev/null +++ b/iota-storage/src/util/sync.rs @@ -0,0 +1,307 @@ +/* Durable per-user state journal used by device cache synchronization. */ +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::{Transaction, params}; +use std::collections::BTreeMap; + +pub const CACHE_SCHEMA_VERSION: i64 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntityType { + Message, + Contact, + Setting, +} +impl EntityType { + fn as_str(self) -> &'static str { + match self { + Self::Message => "message", + Self::Contact => "contact", + Self::Setting => "setting", + } + } +} + +#[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 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 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(()) + }) +} + +/// 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), + _ => {} + } + } + 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 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 new file mode 100644 index 0000000..ef7c574 --- /dev/null +++ b/iota-storage/src/util/synced_settings.rs @@ -0,0 +1,661 @@ +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-terms/Cargo.toml b/iota-terms/Cargo.toml new file mode 100644 index 0000000..c53c3cf --- /dev/null +++ b/iota-terms/Cargo.toml @@ -0,0 +1,10 @@ +[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 new file mode 100644 index 0000000..601ab1f --- /dev/null +++ b/iota-terms/src/consent.rs @@ -0,0 +1,86 @@ +//! 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 new file mode 100644 index 0000000..7510407 --- /dev/null +++ b/iota-terms/src/doc.rs @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..58e6de0 --- /dev/null +++ b/iota-terms/src/lib.rs @@ -0,0 +1,13 @@ +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 new file mode 100755 index 0000000..9ccfffa --- /dev/null +++ b/iota-terms/src/terms_getter.rs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..1fbbef9 --- /dev/null +++ b/iota-updater/Cargo.toml @@ -0,0 +1,15 @@ +[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 new file mode 100644 index 0000000..ec5d061 --- /dev/null +++ b/iota-updater/src/lib.rs @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..d566dc9 --- /dev/null +++ b/iota-updater/src/main.rs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..f2bae0a --- /dev/null +++ b/iota-updater/src/manifest.rs @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..342b2f8 --- /dev/null +++ b/iota-updater/src/transaction.rs @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..676d02f --- /dev/null +++ b/iota-util/Cargo.toml @@ -0,0 +1,22 @@ +[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 new file mode 100644 index 0000000..9800a91 --- /dev/null +++ b/iota-util/src/atomic_file.rs @@ -0,0 +1,158 @@ +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 new file mode 100644 index 0000000..339dd1f --- /dev/null +++ b/iota-util/src/crypto_helper.rs @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..bbb884d --- /dev/null +++ b/iota-util/src/crypto_util.rs @@ -0,0 +1,121 @@ +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/src/util/file_util.rs b/iota-util/src/file_util.rs similarity index 51% rename from src/util/file_util.rs rename to iota-util/src/file_util.rs index 2d00718..0232148 100755 --- a/src/util/file_util.rs +++ b/iota-util/src/file_util.rs @@ -3,14 +3,13 @@ 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); @@ -23,7 +22,7 @@ fn delete_dir_recursive(directory: &Path) -> bool { return false; } if let Err(e) = fs::remove_dir_all(directory) { - log!( + println!( "[IMPORTANT] Couldn't delete directory {}: {}", directory.display(), e, @@ -34,42 +33,126 @@ fn delete_dir_recursive(directory: &Path) -> bool { } #[allow(dead_code)] -pub fn delete_user_directory(user_id: i64) { +pub fn delete_user_directory(user_id: i64) -> io::Result<()> { let user_dir = Path::new(&get_directory()) .join("users") .join(user_id.to_string()); - let _ = delete_dir_recursive(&user_dir); + 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(()) } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { - 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", - )); - } + let file_path = storage_file(path, name)?; // 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 dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return false; + }; + let Some(dir) = file_path.parent() else { + return false; + }; if !dir.exists() { return false; @@ -82,7 +165,9 @@ pub fn has_file(path: &str, name: &str) -> bool { true } pub fn has_dir(path: &str) -> bool { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return false; + }; if !dir.exists() { return false; @@ -92,21 +177,18 @@ pub fn has_dir(path: &str) -> bool { } pub fn load_file(path: &str, name: &str) -> String { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return String::new(); + }; + let Some(dir) = file_path.parent() else { + return String::new(); + }; 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(); } @@ -118,34 +200,38 @@ pub fn load_file(path: &str, name: &str) -> String { } pub fn load_file_vec(path: &str, name: &str) -> Result, std::io::Error> { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); - - std::fs::read(file_path) + std::fs::read(storage_file(path, name)?) } pub fn save_file(path: &str, name: &str, value: &str) { - 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; - } - } - - if let Err(e) = fs::write(&file_path, value) { - log!( - "[IMPORTANT] Couldn't write file {}: {}", - file_path.display(), - e - ); + if let Err(error) = try_save_file(path, name, value) { + eprintln!("[IMPORTANT] Couldn't save file: {error}"); } } +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); + } + Ok(()) +} + pub fn get_children(path: &str) -> Vec { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return Vec::new(); + }; let mut children = Vec::new(); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries { @@ -157,12 +243,61 @@ 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 { - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); - exe.parent() - .unwrap_or(Path::new(".")) - .to_string_lossy() - .to_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() } // Helper to download the zip file content to a file on disk @@ -231,7 +366,7 @@ pub async fn download_zip(url: &str, zip_path: &Path) -> Result<(), Box true, Err(e) => { - log!("Error during ZIP extraction: {}", e); + println!("Error during ZIP extraction: {}", e); false } }; if let Err(e) = tokio::fs::remove_file(&zip_path).await { - log!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); + println!("Error cleaning up ZIP file {}: {}", zip_path.display(), e); } else if successful { - log!("Downloaded and extracted ZIP file successfully."); + 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()); } } diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs new file mode 100644 index 0000000..1b87352 --- /dev/null +++ b/iota-util/src/lib.rs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..adc47d3 --- /dev/null +++ b/iota-util/src/mtp_compat.rs @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..43e4249 --- /dev/null +++ b/iota-util/src/route_target.rs @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..3d05508 --- /dev/null +++ b/iota-util/src/tu.rs @@ -0,0 +1,120 @@ +/* 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 new file mode 100644 index 0000000..1fc0858 --- /dev/null +++ b/iota/Cargo.toml @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 0000000..ca914f8 --- /dev/null +++ b/iota/src/cli_args.rs @@ -0,0 +1,736 @@ +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 new file mode 100644 index 0000000..c893805 --- /dev/null +++ b/iota/src/cli_color.rs @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..1953e78 --- /dev/null +++ b/iota/src/daemon_setup_flow.rs @@ -0,0 +1,245 @@ +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 new file mode 100644 index 0000000..4af10ea --- /dev/null +++ b/iota/src/local_daemon.rs @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..3746707 --- /dev/null +++ b/iota/src/main.rs @@ -0,0 +1,1016 @@ +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 new file mode 100644 index 0000000..4c46c32 --- /dev/null +++ b/iota/src/startup_error.rs @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..b7cdbcb --- /dev/null +++ b/iota/src/terms.rs @@ -0,0 +1,118 @@ +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 new file mode 160000 index 0000000..f4e45aa --- /dev/null +++ b/mtp-type-maps @@ -0,0 +1 @@ +Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml new file mode 100644 index 0000000..5902891 --- /dev/null +++ b/omikron-connector/Cargo.toml @@ -0,0 +1,27 @@ +[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 new file mode 100644 index 0000000..0561212 --- /dev/null +++ b/omikron-connector/src/client.rs @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000..69044c0 --- /dev/null +++ b/omikron-connector/src/lib.rs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..e623daf --- /dev/null +++ b/omikron-connector/src/omega_discovery.rs @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..adb18ee --- /dev/null +++ b/omikron-connector/src/omikron_connection.rs @@ -0,0 +1,2692 @@ +use dashmap::{DashMap, DashSet}; +use iota_logger::{log, log_cv_in, log_cv_out, log_t}; +use iota_state::AppState; +use iota_storage::util::chat_files; +use iota_storage::util::config_util::{CONFIG, modify_config}; +use iota_storage::util::{relay_queue, relay_replay}; +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"); + + 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.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; + } + + 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; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::Iota(router), + &bytes, + now_millis_i64(), + frame_id, + &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; + } + if let Err(error) = + relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id) + { + log!("Relay origin state update failed: {}", error); + } + match self + .await_response(&forwarded, Some(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; + }; + if let (Ok(owner), Ok(signer)) = ( + i64::try_from(verified.context.signer_id), + i64::try_from(verified.context.signer_id), + ) { + if let Err(error) = chat_files::record_destination_iota_received( + owner, + signer, + &verified.context.message_id, + destination_accepted_at, + ) { + log!( + "Relay destination acknowledgement storage failed: {}", + error + ); + } + } + if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { + log!( + "Relay origin acknowledgement could not clear the queue: {}", + error + ); + } + if let Err(error) = relay_replay::mark_downstream_acked( + verified.context.signer_id, + &verified.context.message_id, + ) { + log!("Relay origin delivery state update failed: {}", error); + } + 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()); + 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; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::User(destination), + &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 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 flush_pending_relays(&self) { + let Ok(records) = relay_queue::list(100) else { + return; + }; + for record in records { + 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; + }; + 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_response(&forwarded, Some(Duration::from_secs(20))) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = + relay_queue::acknowledge_iota(destination_iota, record.frame_id) + { + log!( + "Pending Relay {} acknowledgement could not clear the queue: {}", + record.id, + error + ); + } + } + Ok(response) => log!( + "Pending Relay {} route returned {}", + 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()) + { + match relay_queue::acknowledge(destination_id, frame_id) { + Ok(true) => { + if let Err(error) = + relay_replay::mark_delivered_for_frame(destination_id, frame_id) + { + log!("Relay delivery state update failed: {}", error); + } + return; + } + Ok(false) => {} + 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!(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; + } + + // ------------------------------------------------------------------------- + // 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 + )) + } + } + } + + 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 new file mode 100644 index 0000000..5c33416 --- /dev/null +++ b/omikron-connector/src/user_ops.rs @@ -0,0 +1,666 @@ +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 new file mode 100644 index 0000000..1fb5337 --- /dev/null +++ b/other-iota/Cargo.toml @@ -0,0 +1,6 @@ +[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 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/other-iota/src/lib.rs @@ -0,0 +1 @@ + diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7190a60 --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json" +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs deleted file mode 100644 index 73d6442..0000000 --- a/src/auth/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod auth_user; -pub mod local_auth; diff --git a/src/gui/app_state.rs b/src/gui/app_state.rs deleted file mode 100755 index a161226..0000000 --- a/src/gui/app_state.rs +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index b189bd4..0000000 --- a/src/gui/elements/console_card.rs +++ /dev/null @@ -1,491 +0,0 @@ -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/src/gui/elements/graph_card.rs b/src/gui/elements/graph_card.rs deleted file mode 100644 index d95f937..0000000 --- a/src/gui/elements/graph_card.rs +++ /dev/null @@ -1,215 +0,0 @@ -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/src/gui/input_handler.rs b/src/gui/input_handler.rs deleted file mode 100644 index b2557c5..0000000 --- a/src/gui/input_handler.rs +++ /dev/null @@ -1,56 +0,0 @@ -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/src/gui/screens/main_screen.rs b/src/gui/screens/main_screen.rs deleted file mode 100644 index 85dbbaa..0000000 --- a/src/gui/screens/main_screen.rs +++ /dev/null @@ -1,242 +0,0 @@ -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/src/gui/screens/screens.rs b/src/gui/screens/screens.rs deleted file mode 100644 index 9676fbb..0000000 --- a/src/gui/screens/screens.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::any::Any; - -use crossterm::event::KeyEvent; -use ratatui::{Frame, layout::Rect}; - -use crate::gui::interaction_result::InteractionResult; - -#[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); - fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; -} diff --git a/src/gui/ui.rs b/src/gui/ui.rs deleted file mode 100644 index 5903a4b..0000000 --- a/src/gui/ui.rs +++ /dev/null @@ -1,167 +0,0 @@ -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/src/langu/mod.rs b/src/langu/mod.rs deleted file mode 100644 index 352d639..0000000 --- a/src/langu/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod language_creator; -pub mod language_manager; diff --git a/src/omikron/mod.rs b/src/omikron/mod.rs deleted file mode 100644 index acb0f59..0000000 --- a/src/omikron/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod omikron_connection; -pub mod ping_pong_task; diff --git a/src/omikron/omikron_connection.rs b/src/omikron/omikron_connection.rs deleted file mode 100755 index 240da6f..0000000 --- a/src/omikron/omikron_connection.rs +++ /dev/null @@ -1,1172 +0,0 @@ -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 deleted file mode 100644 index f1a891f..0000000 --- a/src/omikron/ping_pong_task.rs +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100755 index f9a87a4..0000000 --- a/src/server/api.rs +++ /dev/null @@ -1,190 +0,0 @@ -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/src/terms/buttons.rs b/src/terms/buttons.rs deleted file mode 100644 index c545a4c..0000000 --- a/src/terms/buttons.rs +++ /dev/null @@ -1,175 +0,0 @@ -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/src/terms/doc.rs b/src/terms/doc.rs deleted file mode 100644 index 2365ba3..0000000 --- a/src/terms/doc.rs +++ /dev/null @@ -1,73 +0,0 @@ -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/src/terms/mod.rs b/src/terms/mod.rs deleted file mode 100644 index 4a11008..0000000 --- a/src/terms/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100755 index a0fa53b..0000000 --- a/src/terms/terms_getter.rs +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 632cea3..0000000 --- a/src/users/contact.rs +++ /dev/null @@ -1,61 +0,0 @@ -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/src/users/user_community_util.rs b/src/users/user_community_util.rs deleted file mode 100644 index 45b036c..0000000 --- a/src/users/user_community_util.rs +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index bcb5e26..0000000 --- a/src/users/user_manager.rs +++ /dev/null @@ -1,197 +0,0 @@ -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/src/util/chat_files.rs b/src/util/chat_files.rs deleted file mode 100644 index e54fe29..0000000 --- a/src/util/chat_files.rs +++ /dev/null @@ -1,276 +0,0 @@ -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 deleted file mode 100644 index b8e0ad2..0000000 --- a/src/util/chats_util.rs +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index ed8b7ae..0000000 --- a/src/util/communities_util.rs +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 6330b59..0000000 --- a/src/util/config_util.rs +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 4a7fd76..0000000 --- a/src/util/crypto_helper.rs +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index bb784e8..0000000 --- a/src/util/crypto_util.rs +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 9fb40a0..0000000 --- a/src/util/db.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! 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/src/util/mod.rs b/src/util/mod.rs deleted file mode 100644 index 47901f9..0000000 --- a/src/util/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -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 new file mode 100644 index 0000000..a5a2903 --- /dev/null +++ b/systemd/iota-daemon.service @@ -0,0 +1,31 @@ +[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 new file mode 100644 index 0000000..4030172 --- /dev/null +++ b/systemd/iota-daemon.socket @@ -0,0 +1,16 @@ +[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 new file mode 100644 index 0000000..5a103af --- /dev/null +++ b/systemd/sysusers.d/iota.conf @@ -0,0 +1,2 @@ +g iota-operators - +u iota - "Tensamin Iota daemon" /var/lib/iota diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml new file mode 100644 index 0000000..c07501f --- /dev/null +++ b/web-server/Cargo.toml @@ -0,0 +1,13 @@ + +[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 new file mode 100644 index 0000000..9cbabb2 --- /dev/null +++ b/web-server/src/lib.rs @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..679a1f2 --- /dev/null +++ b/web-ui/Cargo.toml @@ -0,0 +1,19 @@ +[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 new file mode 100755 index 0000000..4359c17 --- /dev/null +++ b/web-ui/src/api.rs @@ -0,0 +1,275 @@ +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/src/server/mod.rs b/web-ui/src/lib.rs similarity index 100% rename from src/server/mod.rs rename to web-ui/src/lib.rs diff --git a/src/server/server.rs b/web-ui/src/server.rs similarity index 77% rename from src/server/server.rs rename to web-ui/src/server.rs index b55a50f..f799b23 100644 --- a/src/server/server.rs +++ b/web-ui/src/server.rs @@ -1,10 +1,9 @@ -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 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 rustls::ServerConfig; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::{ @@ -15,44 +14,43 @@ 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) -> bool { +pub async fn start(port: u16, state: Arc) -> 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 0.0.0.0:{}", port); + log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, 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(("0.0.0.0", port)) + .bind((bind_ip, port)) .unwrap() .run() } Ok(_) => { - log!("HTTP Server running on 0.0.0.0:{}", port); + log!("HTTP Server running on {}:{}", bind_addr, port); + let app_state = server_state.clone(); 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(("0.0.0.0", port)) + .bind((bind_ip, port)) .unwrap() .run() } @@ -65,15 +63,15 @@ pub async fn start(port: u16) -> bool { let server_handle = server.handle(); tx.send(server_handle).unwrap(); - ACTIVE_TASKS.insert("WebServer".into()); + server_state.active_tasks.insert("WebServer".into()); server.await.unwrap(); - ACTIVE_TASKS.remove("WebServer"); + server_state.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).await; + wait_for_shutdown(server_handle, state).await; }); true } else { @@ -81,9 +79,9 @@ pub async fn start(port: u16) -> bool { } } -async fn wait_for_shutdown(server_handle: ServerHandle) { +async fn wait_for_shutdown(server_handle: ServerHandle, state: Arc) { loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { log!("Shutdown signal received."); server_handle.stop(true).await; break; diff --git a/web-ui/src/socket.rs b/web-ui/src/socket.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/server/web_path_parser.rs b/web-ui/src/web_path_parser.rs similarity index 98% rename from src/server/web_path_parser.rs rename to web-ui/src/web_path_parser.rs index a82c8db..2eaf3cf 100755 --- a/src/server/web_path_parser.rs +++ b/web-ui/src/web_path_parser.rs @@ -1,7 +1,7 @@ use actix_web::{HttpRequest, HttpResponse}; use std::path::{Path, PathBuf}; -use crate::util::file_util::load_file_vec; +use iota_util::file_util::load_file_vec; fn codec_for_ext(ext: &str) -> &'static str { match ext {