From 2dae6a4d5e0b85484bebb786203d6b7143512cfe Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Wed, 6 May 2026 01:02:33 -0600 Subject: [PATCH] Initial commit: v0.9.0 release --- .github/workflows/ci.yml | 64 + .github/workflows/release.yml | 111 + .gitignore | 4 + Cargo.lock | 2455 ++++++++++++++++++++ Cargo.toml | 61 + LICENSE | 661 ++++++ README.md | 336 +++ crates/lxmf-core/Cargo.toml | 29 + crates/lxmf-core/src/constants.rs | 377 +++ crates/lxmf-core/src/discovery_stamper.rs | 236 ++ crates/lxmf-core/src/handlers.rs | 1123 +++++++++ crates/lxmf-core/src/lib.rs | 58 + crates/lxmf-core/src/link_delivery.rs | 1448 ++++++++++++ crates/lxmf-core/src/message.rs | 2285 ++++++++++++++++++ crates/lxmf-core/src/peer.rs | 688 ++++++ crates/lxmf-core/src/persist.rs | 133 ++ crates/lxmf-core/src/propagation.rs | 546 +++++ crates/lxmf-core/src/propagation_client.rs | 1288 ++++++++++ crates/lxmf-core/src/propagation_node.rs | 1787 ++++++++++++++ crates/lxmf-core/src/propagation_sync.rs | 1065 +++++++++ crates/lxmf-core/src/router.rs | 2251 ++++++++++++++++++ crates/lxmf-core/src/stamper.rs | 583 +++++ crates/lxmf-core/src/sync.rs | 556 +++++ crates/lxmf-core/src/ticket.rs | 161 ++ crates/lxmf-tools/Cargo.toml | 28 + crates/lxmf-tools/src/bin/lxmd-rs.rs | 6 + crates/lxmf-tools/src/commands/lxmd.rs | 2311 ++++++++++++++++++ crates/lxmf-tools/src/daemon.rs | 657 ++++++ crates/lxmf-tools/src/lib.rs | 6 + crates/lxmf-tools/src/lxmd_cli.rs | 336 +++ crates/lxmf-tools/src/lxmd_control.rs | 1035 +++++++++ crates/lxmf-tools/src/lxmd_runtime.rs | 564 +++++ crates/lxmf-tools/tests/lxmd_cli.rs | 329 +++ 33 files changed, 23578 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crates/lxmf-core/Cargo.toml create mode 100644 crates/lxmf-core/src/constants.rs create mode 100644 crates/lxmf-core/src/discovery_stamper.rs create mode 100644 crates/lxmf-core/src/handlers.rs create mode 100644 crates/lxmf-core/src/lib.rs create mode 100644 crates/lxmf-core/src/link_delivery.rs create mode 100644 crates/lxmf-core/src/message.rs create mode 100644 crates/lxmf-core/src/peer.rs create mode 100644 crates/lxmf-core/src/persist.rs create mode 100644 crates/lxmf-core/src/propagation.rs create mode 100644 crates/lxmf-core/src/propagation_client.rs create mode 100644 crates/lxmf-core/src/propagation_node.rs create mode 100644 crates/lxmf-core/src/propagation_sync.rs create mode 100644 crates/lxmf-core/src/router.rs create mode 100644 crates/lxmf-core/src/stamper.rs create mode 100644 crates/lxmf-core/src/sync.rs create mode 100644 crates/lxmf-core/src/ticket.rs create mode 100644 crates/lxmf-tools/Cargo.toml create mode 100644 crates/lxmf-tools/src/bin/lxmd-rs.rs create mode 100644 crates/lxmf-tools/src/commands/lxmd.rs create mode 100644 crates/lxmf-tools/src/daemon.rs create mode 100644 crates/lxmf-tools/src/lib.rs create mode 100644 crates/lxmf-tools/src/lxmd_cli.rs create mode 100644 crates/lxmf-tools/src/lxmd_control.rs create mode 100644 crates/lxmf-tools/src/lxmd_runtime.rs create mode 100644 crates/lxmf-tools/tests/lxmd_cli.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b5868b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test (${{ matrix.os }}) + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + path: rsLXMF + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: main + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXMF -> target + - name: Install system deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config + - run: cargo test --workspace + working-directory: rsLXMF + + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + path: rsLXMF + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: main + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXMF -> target + - name: Install system deps + run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config + - run: cargo fmt --all -- --check + working-directory: rsLXMF + - run: cargo clippy --workspace --all-targets -- -D warnings + working-directory: rsLXMF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ef78a67 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to create or update" + required: true + +permissions: + contents: read + +env: + RELEASE_TAG: ${{ github.event.inputs.tag_name || github.ref_name }} + PACKAGE_ROOT: rsLXMF + LXMF_BINS: lxmd-rs + +jobs: + build: + name: Build ${{ matrix.artifact_suffix }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact_suffix: linux-x86_64 + archive: tar.gz + - os: macos-latest + artifact_suffix: macos + archive: tar.gz + - os: windows-latest + artifact_suffix: windows-x86_64 + archive: zip + runs-on: ${{ matrix.os }} + env: + ARCHIVE_NAME: rsLXMF-${{ github.event.inputs.tag_name || github.ref_name }}-${{ matrix.artifact_suffix }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + path: rsLXMF + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RELEASE_TAG }} + path: rsReticulum + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rsLXMF -> target + - name: Install system deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config + - name: Build release binaries + working-directory: rsLXMF + run: cargo build -p lxmf-tools --release --bins + - name: Stage archive contents + shell: bash + working-directory: rsLXMF + run: | + set -euo pipefail + mkdir -p "dist/${PACKAGE_ROOT}/bin" + for bin in ${LXMF_BINS}; do + if [[ "${RUNNER_OS}" == "Windows" ]]; then + cp "target/release/${bin}.exe" "dist/${PACKAGE_ROOT}/bin/${bin}.exe" + else + cp "target/release/${bin}" "dist/${PACKAGE_ROOT}/bin/${bin}" + fi + done + cp README.md LICENSE "dist/${PACKAGE_ROOT}/" + - name: Create tar archive + if: runner.os != 'Windows' + shell: bash + working-directory: rsLXMF + run: tar -czf "${ARCHIVE_NAME}.tar.gz" -C dist "${PACKAGE_ROOT}" + - name: Create zip archive + if: runner.os == 'Windows' + shell: pwsh + working-directory: rsLXMF + run: Compress-Archive -Path "dist/${env:PACKAGE_ROOT}" -DestinationPath "${env:ARCHIVE_NAME}.zip" -Force + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARCHIVE_NAME }} + path: | + rsLXMF/*.tar.gz + rsLXMF/*.zip + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + - name: Generate checksums + working-directory: release-assets + run: sha256sum * > SHA256SUMS + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + prerelease: true + files: release-assets/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e492ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +*.swp +.DS_Store +proptest-regressions/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c7d3fc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2455 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[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-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.29.0", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "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" +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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "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", +] + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "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", +] + +[[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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[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", +] + +[[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 = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[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", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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 = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "lxmf-core" +version = "0.9.0" +dependencies = [ + "base64", + "bytes", + "hex", + "proptest", + "rand 0.8.5", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-link", + "rns-protocol", + "rns-transport", + "rns-wire", + "serde", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "lxmf-tools" +version = "0.9.0" +dependencies = [ + "base64", + "bytes", + "clap", + "hex", + "lxmf-core", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-runtime", + "rns-transport", + "rns-wire", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-core-bluetooth" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[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 = "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 = "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", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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", +] + +[[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 = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +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_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.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_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rns-crypto" +version = "0.9.0" +dependencies = [ + "aes", + "cbc", + "ed25519-dalek", + "hkdf", + "hmac", + "rand 0.8.5", + "rand_core 0.6.4", + "sha2", + "subtle", + "thiserror 2.0.18", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "rns-identity" +version = "0.9.0" +dependencies = [ + "hex", + "rand 0.8.5", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-wire", + "serde", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-interface" +version = "0.9.0" +dependencies = [ + "bluer", + "bytes", + "hex", + "if-addrs", + "jni", + "libc", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "rand 0.8.5", + "rns-crypto", + "rns-transport", + "rns-wire", + "serde", + "serde_json", + "serialport", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "rns-link" +version = "0.9.0" +dependencies = [ + "hex", + "rand 0.8.5", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "rns-protocol" +version = "0.9.0" +dependencies = [ + "bzip2", + "hex", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-link", + "rns-wire", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-runtime" +version = "0.9.0" +dependencies = [ + "bytes", + "hex", + "hmac", + "nix 0.29.0", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-interface", + "rns-link", + "rns-protocol", + "rns-transport", + "rns-wire", + "serde", + "sha2", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-transport" +version = "0.9.0" +dependencies = [ + "bytes", + "hex", + "rand 0.8.5", + "rmp-serde", + "rmpv", + "rns-crypto", + "rns-identity", + "rns-wire", + "serde", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "rns-wire" +version = "0.9.0" +dependencies = [ + "rns-crypto", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[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", +] + +[[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 = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "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 = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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 = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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", +] + +[[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", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "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 = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +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.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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 = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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", + "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]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[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", + "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", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..99e188c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +[workspace] +resolver = "2" +members = [ + "crates/lxmf-core", + "crates/lxmf-tools", +] + +[workspace.package] +version = "0.9.0" +edition = "2024" +license = "AGPL-3.0-or-later" +rust-version = "1.85" + +[workspace.dependencies] +# Reticulum crates from a sibling rsReticulum checkout during development. +rns-crypto = { path = "../rsReticulum/crates/rns-crypto" } +rns-wire = { path = "../rsReticulum/crates/rns-wire" } +rns-identity = { path = "../rsReticulum/crates/rns-identity" } +rns-link = { path = "../rsReticulum/crates/rns-link" } +rns-protocol = { path = "../rsReticulum/crates/rns-protocol" } +rns-transport = { path = "../rsReticulum/crates/rns-transport" } +rns-runtime = { path = "../rsReticulum/crates/rns-runtime" } +rns-interface = { path = "../rsReticulum/crates/rns-interface" } + +# Workspace LXMF crates +lxmf-core = { path = "crates/lxmf-core" } +lxmf-tools = { path = "crates/lxmf-tools" } + +# Encoding +base64 = "0.22" + +# Serialization +rmp-serde = "1" +rmpv = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Async +tokio = { version = "1", features = ["full"] } +bytes = "1" + +# Crypto +sha2 = "0.10" +rand = "0.8" + +# CLI +clap = { version = "4", features = ["derive"] } + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" + +# Error handling +thiserror = "2" + +# Misc +hex = "0.4" +tempfile = "3" + +# Testing +proptest = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..41d06c1 --- /dev/null +++ b/README.md @@ -0,0 +1,336 @@ +
+ +# rsLXMF + +**Pure-Rust LXMF messaging and propagation for Reticulum.** + +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE) +[![Rust 1.85+](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org) +[![LXMF 0.9.6](https://img.shields.io/badge/target-LXMF%200.9.6-success.svg)](https://github.com/markqvist/LXMF) +[![Status](https://img.shields.io/badge/status-experimental-yellow.svg)](#feature-status) + +[LXMF Reference](https://github.com/markqvist/LXMF) | +[Reticulum Manual](https://reticulum.network/manual/) | +[rsReticulum](https://github.com/ratspeak/rsReticulum) | +[Ratspeak](https://github.com/ratspeak/Ratspeak) + +
+ +--- + +rsLXMF is a Rust implementation of LXMF, the Reticulum messaging layer. This is not a fork of LXMF, this is LXMF written in a different language focused on staying interoperable. This is not a source of truth implementation, do not use it as such. + +Commands are intentionally namespaced for Rust with the Rust-specific `lxmd-rs` command, so rsLXMF can live beside other +LXMF daemons on `PATH` without worry. + +## Contents + +- [Build It](#build-it) +- [Operating lxmd-rs](#operating-lxmd-rs) +- [Configuration](#configuration) +- [Delivery Model](#delivery-model) +- [Feature Status](#feature-status) +- [Compatibility Notes](#compatibility-notes) +- [Contributing](#contributing) +- [License](#license) + +## Build It + +The current development layout for rsLXMF requires +rsReticulum as a sibling directory/repo next to it, such as: + +```text +ratspeak-src/ +|-- rsReticulum/ +`-- rsLXMF/ +``` +If you're starting fresh: +```bash +mkdir ratspeak-src +cd ratspeak-src +git clone https://github.com/ratspeak/rsReticulum +git clone https://github.com/ratspeak/rsLXMF +cd rsLXMF +``` + +### macOS + +Install Rust with `rustup`, then install Apple's build tools: + +```bash +xcode-select --install +``` + +Build from the sibling checkout: + +```bash +cd rsLXMF +cargo build --release +``` + +### Linux / Raspberry Pi + +#### Install Rust with `rustup`, then install the needed packages: + +Debian, Ubuntu, and Raspberry Pi OS: + +```bash +sudo apt update +sudo apt install -y build-essential pkg-config libudev-dev +``` + +Fedora: + +```bash +sudo dnf install gcc make pkgconf-pkg-config systemd-devel +``` + +Arch: + +```bash +sudo pacman -S --needed base-devel pkgconf systemd +``` + +#### Build the daemon: + +```bash +cd rsLXMF +cargo build --release +``` + +### Windows + +Install Rust with the MSVC toolchain. If Rust or Cargo asks for Visual Studio +Build Tools, install the "Desktop development with C++" workload. + +Build from PowerShell: + +```powershell +cd rsLXMF +cargo build --release +``` + +After the build, use the commands below with `./target/release/lxmd-rs` on +macOS/Linux or `.\target\release\lxmd-rs.exe` on Windows. + +## Operating lxmd-rs + +`lxmf-tools` builds one public command name: + +| Binary | Purpose | +| --- | --- | +| lxmd-rs | Rust LXMF daemon and control utility. | + + + +Generate the example config: + +```bash +lxmd-rs --exampleconfig +``` + +Run a regular LXMF daemon: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum +``` + +Run a propagation node: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --propagation-node +``` + +Send a message and exit: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum \ + --send "message body" +``` + +The `--send` flag is an rsLXMF convenience. Normal +daemon and propagation-control operation does not require it for anything. + +Send UTF-8 file content or select a delivery method: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum \ + --send --send-file ./message.txt --send-method direct +``` + +Supported `--send-method` values are `opportunistic`, `direct`, and +`propagated`. Paper messages aren't supported yet in the CLI. + +Attach custom LXMF fields from scripts: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum \ + --send "with fields" \ + --send-fields-json '{"1":"aGVsbG8=","42":"AAECAw=="}' +``` + +The JSON object maps field IDs to base64-encoded bytes. It is only a shell +convenience; LXMF fields remain MessagePack field maps on the wire. + +Control a propagation node: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --status +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --peers +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --sync +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --break +``` + +These commands query an `lxmf.propagation.control` endpoint over Reticulum. +They do not inspect local files and do not start local daemon state. If no +reachable daemon answers, they time out with compatibility-oriented control +exit behavior. + +For a remote propagation node, pass the node's propagation destination hash: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum \ + --status --peers --remote --timeout 5 +``` + +Control queries use `/identity` by default. Use `--identity PATH` +when the query should authenticate as a different LXMF identity. + +Run an inbound hook: + +```bash +lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --on-inbound /path/to/handler +``` + +The handler receives the saved `.lxm` message path as an argument. + +## Configuration + +`lxmd-rs --config ` expects a directory and reads `/config`. +`lxmd-rs --rnsconfig ` expects a Reticulum config directory. + +If no LXMF config directory is supplied, the default is: + +| Platform | Default LXMF config file | +| --- | --- | +| Linux/macOS | `/etc/rsLXMF/config`, then `~/.config/rsLXMF/config`, then `~/.rsLXMF/config` | +| Windows | `%APPDATA%\rsLXMF\config` | + + +If `--rnsconfig` is omitted, Reticulum config resolution follows +rsReticulum-specific defaults. + +Recommended standalone locations: + +| Environment | LXMF config | Reticulum config | +| --- | --- | --- | +| macOS/Linux desktop | `~/.rsLXMF/config` | `~/.rsReticulum/config` | +| Windows desktop | `%APPDATA%\rsLXMF\config` | `%APPDATA%\rsReticulum\config` | +| Linux service | `/var/lib/rsLXMF/config` | `/etc/rsReticulum/config` or another explicit Reticulum directory | + +Use existing LXMF or Reticulum directories, such as `~/.lxmd`, `~/.lxmf`, or +`~/.reticulum`, only by passing them explicitly. That keeps the default install +isolated while still allowing deliberate drop-in and migration tests. + +Minimal config: + +```ini +[lxmf] +display_name = Rat +announce_at_start = no +delivery_transfer_max_accepted_size = 1000 +# stamp_cost = 8 +# on_inbound = /path/to/handler + +[propagation] +enable_node = no +announce_at_start = yes +autopeer = yes +autopeer_maxdepth = 6 +auth_required = no +# node_name = Rat Nest +# static_peers = e17f833c4ddf8890dd3a79a6fea8161d +# outbound_node = e17f833c4ddf8890dd3a79a6fea8161d +# max_peers = 20 +# propagation_stamp_cost_target = 16 +# propagation_stamp_cost_flexibility = 3 + +[logging] +loglevel = 4 +``` + +Supported sections: + +| Section | Keys | +| --- | --- | +| `[lxmf]` | `display_name`, `announce_at_start`, `announce_interval`, `delivery_transfer_max_accepted_size`, `stamp_cost`, `on_inbound` | +| `[propagation]` | `enable_node`, `node_name`, `auth_required`, `announce_at_start`, `announce_interval`, `autopeer`, `autopeer_maxdepth`, `message_storage_limit`, `propagation_message_max_accepted_size`, `propagation_sync_max_accepted_size`, `propagation_stamp_cost_target`, `propagation_stamp_cost_flexibility`, `peering_cost`, `remote_peering_cost_max`, `max_peers`, `static_peers`, `prioritise_destinations`, `control_allowed`, `from_static_only`, `outbound_node`, `propagation_stamp_cost`, `propagation_limit`, `enforce_ratchets`, `enforce_stamps` | +| `[control]` | `auth_required`, `allowed` | +| `[logging]` | `loglevel` | + +Optional hash-list files live next to `/config`: + +| File | Meaning | +| --- | --- | +| `ignored` | Hashes loaded into the router ignored list. | +| `allowed` | Hashes loaded into the router delivery allow-list. Empty means no allow-list restriction. | + +Hash-list files accept one raw 32-character hex destination hash per line. + +## Delivery Model + +LXMF supports several delivery shapes. rsLXMF exposes them through the router +and, where network-backed, through `lxmd-rs --send-method`. + +| Method | Behavior | +| --- | --- | +| Opportunistic | Single-packet delivery when the packed message fits the Reticulum packet path. Oversized opportunistic messages are downgraded to Direct by the router. | +| Direct | Link-backed delivery over a Reticulum Link, with resource transfer for larger content. | +| Propagated | Store-and-forward delivery through a propagation node, including deposit, retrieve, peer sync, stamps, and tickets. | +| Paper | Library support for `lxm://` URI generation and ingest. The CLI does not generate QR images. | + +An ordinary direct or opportunistic LXMF message is a signed Reticulum payload: + +```text +destination_hash 16 bytes +source_hash 16 bytes +signature 64 bytes +payload MessagePack([timestamp, title, content, fields, optional_stamp]) +``` + +`title` and `content` are bytes on the wire. `fields` is a `map` for +application-defined data such as tickets, attachments, location data, or +application envelopes. + + +## Feature Status + +| Area | Current behavior | +| --- | --- | +| Message format | Signed LXMF envelopes, custom field maps, propagation wrappers, `.lxm` containers, and paper URI encode/decode. | +| Delivery | Opportunistic, Direct, Propagated, callbacks, failure callbacks, cancellation, progress state, and opportunistic-to-direct downgrade. | +| Propagation | Disk-backed store, deposit, retrieve, peer sync, autopeer/static peers, weighted culling, duplicate checks, size checks, and stamp checks. | +| Stamps and tickets | Soft/hard stamp validation, HKDF-expanded workblocks, cached destination stamp costs, propagation tickets, and restart-safe ticket persistence. | +| Control | `--status`, `--peers`, `--sync`, and `--break` over the propagation-control link. | +| Access lists | `ignored` and `allowed` hash-list files in the LXMF config directory. | + +## Compatibility Notes + +Most daemon and control flags are implemented: `--config`, `--rnsconfig`, +`--propagation-node`, `--on-inbound`, `--status`, `--peers`, `--sync`, +`--break`, `--remote`, `--identity`, `--timeout`, `--exampleconfig`, and +`--version`. + +Additional rsLXMF-only flags: `--send`, `--send-file`, `--send-method`, +`--send-timeout-secs`, and `--send-fields-json`. + +## Contributing + +If the issue or contribution belongs upstream as well, start there. Python LXMF +and Reticulum remain the reference implementations. + +PRs are closed for now until I have time to catch up on everything. I'm tired. + +## License + +GNU Affero General Public License v3.0 or later. See [LICENSE](LICENSE). diff --git a/crates/lxmf-core/Cargo.toml b/crates/lxmf-core/Cargo.toml new file mode 100644 index 0000000..5232133 --- /dev/null +++ b/crates/lxmf-core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "lxmf-core" +description = "LXMF messaging protocol implementation for Reticulum" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +base64 = { workspace = true } +rns-crypto = { workspace = true } +rns-wire = { workspace = true } +rns-identity = { workspace = true } +rns-link = { workspace = true } +rns-protocol = { workspace = true } +rns-transport = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +bytes = { workspace = true } +tracing = { workspace = true } +rmp-serde = { workspace = true } +rmpv = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +hex = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +proptest = { workspace = true } diff --git a/crates/lxmf-core/src/constants.rs b/crates/lxmf-core/src/constants.rs new file mode 100644 index 0000000..b4c6641 --- /dev/null +++ b/crates/lxmf-core/src/constants.rs @@ -0,0 +1,377 @@ +//! LXMF protocol constants. +//! +//! Python reference: LXMF/LXMF.py. + +pub const FIELD_EMBEDDED_LXMS: u8 = 0x01; +pub const FIELD_TELEMETRY: u8 = 0x02; +pub const FIELD_TELEMETRY_STREAM: u8 = 0x03; +pub const FIELD_ICON_APPEARANCE: u8 = 0x04; +pub const FIELD_FILE_ATTACHMENTS: u8 = 0x05; +pub const FIELD_IMAGE: u8 = 0x06; +pub const FIELD_AUDIO: u8 = 0x07; +pub const FIELD_THREAD: u8 = 0x08; +pub const FIELD_COMMANDS: u8 = 0x09; +pub const FIELD_RESULTS: u8 = 0x0A; +pub const FIELD_GROUP: u8 = 0x0B; +pub const FIELD_TICKET: u8 = 0x0C; +pub const FIELD_EVENT: u8 = 0x0D; +pub const FIELD_RNR_REFS: u8 = 0x0E; +pub const FIELD_RENDERER: u8 = 0x0F; +pub const FIELD_CUSTOM_TYPE: u8 = 0xFB; +pub const FIELD_CUSTOM_DATA: u8 = 0xFC; +pub const FIELD_CUSTOM_META: u8 = 0xFD; +pub const FIELD_NON_SPECIFIC: u8 = 0xFE; +pub const FIELD_DEBUG: u8 = 0xFF; + +pub const AM_CODEC2_450PWB: u8 = 0x01; +pub const AM_CODEC2_450: u8 = 0x02; +pub const AM_CODEC2_700C: u8 = 0x03; +pub const AM_CODEC2_1200: u8 = 0x04; +pub const AM_CODEC2_1300: u8 = 0x05; +pub const AM_CODEC2_1400: u8 = 0x06; +pub const AM_CODEC2_1600: u8 = 0x07; +pub const AM_CODEC2_2400: u8 = 0x08; +pub const AM_CODEC2_3200: u8 = 0x09; +pub const AM_OPUS_OGG: u8 = 0x10; +pub const AM_OPUS_LBW: u8 = 0x11; +pub const AM_OPUS_MBW: u8 = 0x12; +pub const AM_OPUS_PTT: u8 = 0x13; +pub const AM_OPUS_RT_HDX: u8 = 0x14; +pub const AM_OPUS_RT_FDX: u8 = 0x15; +pub const AM_OPUS_STANDARD: u8 = 0x16; +pub const AM_OPUS_HQ: u8 = 0x17; +pub const AM_OPUS_BROADCAST: u8 = 0x18; +pub const AM_OPUS_LOSSLESS: u8 = 0x19; +pub const AM_CUSTOM: u8 = 0xFF; + +pub const RENDERER_PLAIN: u8 = 0x00; +pub const RENDERER_MICRON: u8 = 0x01; +pub const RENDERER_MARKDOWN: u8 = 0x02; +pub const RENDERER_BBCODE: u8 = 0x03; + +pub const PN_META_VERSION: u8 = 0x00; +pub const PN_META_NAME: u8 = 0x01; +pub const PN_META_SYNC_STRATUM: u8 = 0x02; +pub const PN_META_SYNC_THROTTLE: u8 = 0x03; +pub const PN_META_AUTH_BAND: u8 = 0x04; +pub const PN_META_UTIL_PRESSURE: u8 = 0x05; +pub const PN_META_CUSTOM: u8 = 0xFF; + +pub const SF_COMPRESSION: u8 = 0x00; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum MessageState { + Generating = 0x00, + Outbound = 0x01, + Sending = 0x02, + Sent = 0x04, + Delivered = 0x08, + Rejected = 0xFD, + Cancelled = 0xFE, + Failed = 0xFF, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum DeliveryMethod { + Opportunistic = 0x01, + Direct = 0x02, + Propagated = 0x03, + Paper = 0x05, +} + +impl DeliveryMethod { + /// Paper is a local-only generation method and cannot be transmitted. + pub fn is_sendable(&self) -> bool { + !matches!(self, DeliveryMethod::Paper) + } +} + +/// How a message is represented on the wire. Values match Python LXMessage.py. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum DeliveryRepresentation { + Unknown = 0x00, + Packet = 0x01, + Resource = 0x02, + Paper = 0x05, +} + +/// Reason a message could not be verified. Values match Python LXMessage.py. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum UnverifiedReason { + SourceUnknown = 0x01, + SignatureInvalid = 0x02, +} + +pub const DESTINATION_LENGTH: usize = 16; +pub const SIGNATURE_LENGTH: usize = 64; +pub const TICKET_LENGTH: usize = 16; +pub const TIMESTAMP_SIZE: usize = 8; +pub const STRUCT_OVERHEAD: usize = 8; +/// 2 * dest(16) + sig(64) + timestamp(8) + struct(8) = 112. +pub const LXMF_OVERHEAD: usize = + 2 * DESTINATION_LENGTH + SIGNATURE_LENGTH + TIMESTAMP_SIZE + STRUCT_OVERHEAD; +pub const PAPER_MDU: usize = 2210; + +pub const TICKET_EXPIRY: u64 = 21 * 24 * 60 * 60; +pub const TICKET_GRACE: u64 = 5 * 24 * 60 * 60; +pub const TICKET_RENEW: u64 = 14 * 24 * 60 * 60; +pub const TICKET_INTERVAL: u64 = 24 * 60 * 60; +/// Sentinel cost value that always exceeds the maximum PoW cost. +pub const COST_TICKET: u16 = 0x100; + +pub const MAX_DELIVERY_ATTEMPTS: u32 = 5; +/// Interval between router job ticks (seconds). +pub const PROCESSING_INTERVAL: u64 = 4; +pub const DELIVERY_RETRY_WAIT: u64 = 10; +pub const PATH_REQUEST_WAIT: u64 = 7; +pub const MAX_PATHLESS_TRIES: u32 = 1; +/// Maximum link inactivity before teardown (seconds). +pub const LINK_MAX_INACTIVITY: u64 = 10 * 60; +/// Maximum propagation link inactivity (seconds). +pub const P_LINK_MAX_INACTIVITY: u64 = 3 * 60; +pub const MESSAGE_EXPIRY: u64 = 30 * 24 * 60 * 60; +pub const STAMP_COST_EXPIRY: u64 = 45 * 24 * 60 * 60; +/// Delay before announcing propagation node (seconds). +pub const NODE_ANNOUNCE_DELAY: u64 = 20; +pub const PROPAGATION_LIMIT: usize = 256; +pub const DELIVERY_LIMIT: usize = 1000; +/// PROPAGATION_LIMIT * 40, in KB. +pub const SYNC_LIMIT: usize = 10240; +pub const PROPAGATION_COST_MIN: u8 = 13; +pub const PROPAGATION_COST: u8 = 16; +pub const PROPAGATION_COST_FLEX: u8 = 3; +pub const PEERING_COST: u8 = 18; +pub const MAX_PEERING_COST: u8 = 26; +pub const MAX_PEERS: usize = 20; +pub const PN_STAMP_THROTTLE: u64 = 180; +/// Propagation retrieval path timeout (seconds). +pub const PR_PATH_TIMEOUT: u64 = 10; + +pub const AUTOPEER: bool = true; +pub const AUTOPEER_MAXDEPTH: usize = 4; +/// When selecting peers for sync, pick from the N fastest. +pub const FASTEST_N_RANDOM_POOL: usize = 2; +/// Percentage of max_peers kept as headroom for rotation. +pub const ROTATION_HEADROOM_PCT: usize = 10; +/// Acceptance rate below which peers become rotation candidates. +pub const ROTATION_AR_MAX: f64 = 0.5; + +pub const STATS_GET_PATH: &str = "/pn/get/stats"; +pub const SYNC_REQUEST_PATH: &str = "/pn/peer/sync"; +pub const UNPEER_REQUEST_PATH: &str = "/pn/peer/unpeer"; +/// Sentinel value meaning "download all messages". +pub const PR_ALL_MESSAGES: u32 = 0x00; +/// Signal value for duplicate detection during sync. +pub const DUPLICATE_SIGNAL: &str = "lxmf_duplicate"; + +/// Client-side state machine for retrieving messages from a propagation node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +pub enum PropagationRetrievalState { + Idle = 0x00, + PathRequested = 0x01, + LinkEstablishing = 0x02, + LinkEstablished = 0x03, + RequestSent = 0x04, + Receiving = 0x05, + ResponseReceived = 0x06, + Complete = 0x07, + NoPath = 0xF0, + LinkFailed = 0xF1, + TransferFailed = 0xF2, + NoIdentityReceived = 0xF3, + NoAccess = 0xF4, + Failed = 0xFE, +} + +pub const OFFER_REQUEST_PATH: &str = "/offer"; +pub const MESSAGE_GET_PATH: &str = "/get"; +/// Maximum time a peer can be unreachable before removal (14 days). +pub const MAX_UNREACHABLE: u64 = 14 * 24 * 60 * 60; +/// Sync backoff step per consecutive failure (12 minutes). +pub const SYNC_BACKOFF_STEP: u64 = 12 * 60; +pub const PATH_REQUEST_GRACE: f64 = 7.5; +/// Maximum time a peer can be stale before rotation (14 days). +pub const PEER_STALE_TIME: u64 = 14 * 24 * 60 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PeerState { + Idle = 0x00, + LinkEstablishing = 0x01, + LinkReady = 0x02, + RequestSent = 0x03, + ResponseReceived = 0x04, + ResourceTransferring = 0x05, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PeerError { + NoIdentity = 0xF0, + NoAccess = 0xF1, + // 0xF2 is unused (gap in numbering). + InvalidKey = 0xF3, + InvalidData = 0xF4, + InvalidStamp = 0xF5, + Throttled = 0xF6, + NotFound = 0xFD, + Timeout = 0xFE, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +#[derive(Default)] +pub enum SyncStrategy { + Lazy = 0x01, + #[default] + Persistent = 0x02, +} + +/// Default expand rounds for message stamps. Matches Python WORKBLOCK_EXPAND_ROUNDS. +pub const STAMP_WORKBLOCK_EXPAND_ROUNDS: usize = 3000; +/// Expand rounds for propagation node stamps. Matches Python WORKBLOCK_EXPAND_ROUNDS_PN. +pub const STAMP_WORKBLOCK_EXPAND_ROUNDS_PN: usize = 1000; +/// Expand rounds for peering key generation. Matches Python WORKBLOCK_EXPAND_ROUNDS_PEERING. +pub const STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING: usize = 25; +/// SHA-256 output length. +pub const STAMP_SIZE: usize = 32; +/// Minimum batch size before using parallel validation pool. +pub const PN_VALIDATION_POOL_MIN_SIZE: usize = 256; + +/// Interval (in ticks) for processing outbound messages. +pub const JOB_OUTBOUND_INTERVAL: u64 = 1; +/// Interval (in ticks) for processing deferred stamps. +pub const JOB_STAMPS_INTERVAL: u64 = 1; +/// Interval (in ticks) for cleaning inactive links. +pub const JOB_LINKS_INTERVAL: u64 = 1; +/// Interval (in ticks) for cleaning transient ID caches. +pub const JOB_TRANSIENT_INTERVAL: u64 = 60; +/// Interval (in ticks) for cleaning the message store. +pub const JOB_STORE_INTERVAL: u64 = 120; +/// Interval (in ticks) for syncing peers. +pub const JOB_PEERSYNC_INTERVAL: u64 = 6; +/// Interval (in ticks) for ingesting peer distribution queues. +pub const JOB_PEERINGEST_INTERVAL: u64 = 6; +/// 56 * JOB_PEERINGEST_INTERVAL. +pub const JOB_ROTATE_INTERVAL: u64 = 56 * 6; + +pub const APP_NAME: &str = "lxmf"; +pub const DELIVERY_ASPECT: &str = "delivery"; +pub const PROPAGATION_ASPECT: &str = "propagation"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lxmf_overhead() { + assert_eq!(LXMF_OVERHEAD, 112); + } + + #[test] + fn test_message_states_distinct() { + assert_ne!(MessageState::Generating as u8, MessageState::Outbound as u8); + assert_ne!(MessageState::Sent as u8, MessageState::Delivered as u8); + assert_ne!(MessageState::Rejected as u8, MessageState::Failed as u8); + } + + #[test] + fn test_delivery_method_sendable() { + assert!(DeliveryMethod::Opportunistic.is_sendable()); + assert!(DeliveryMethod::Direct.is_sendable()); + assert!(DeliveryMethod::Propagated.is_sendable()); + assert!(!DeliveryMethod::Paper.is_sendable()); + } + + #[test] + fn test_state_and_method_no_overlap() { + // PAPER (0x05) delivery method must not collide with any MessageState value. + let paper = DeliveryMethod::Paper as u8; + assert_ne!(paper, MessageState::Generating as u8); + assert_ne!(paper, MessageState::Outbound as u8); + assert_ne!(paper, MessageState::Sending as u8); + assert_ne!(paper, MessageState::Sent as u8); + assert_ne!(paper, MessageState::Delivered as u8); + } + + #[test] + fn test_ticket_constants() { + assert_eq!(TICKET_EXPIRY, 1_814_400); + assert_eq!(TICKET_GRACE, 432_000); + assert_eq!(TICKET_RENEW, 1_209_600); + assert_eq!(TICKET_INTERVAL, 86_400); + assert_eq!(COST_TICKET, 256); + } + + #[test] + fn test_peer_states_sequential() { + assert_eq!(PeerState::Idle as u8, 0); + assert_eq!(PeerState::LinkEstablishing as u8, 1); + assert_eq!(PeerState::LinkReady as u8, 2); + assert_eq!(PeerState::RequestSent as u8, 3); + assert_eq!(PeerState::ResponseReceived as u8, 4); + assert_eq!(PeerState::ResourceTransferring as u8, 5); + } + + #[test] + fn test_sync_strategy_default() { + assert_eq!(SyncStrategy::default(), SyncStrategy::Persistent); + } + + #[test] + fn test_unverified_reason_values() { + assert_eq!(UnverifiedReason::SourceUnknown as u8, 0x01); + assert_eq!(UnverifiedReason::SignatureInvalid as u8, 0x02); + } + + #[test] + fn test_delivery_representation_values() { + assert_eq!(DeliveryRepresentation::Unknown as u8, 0x00); + assert_eq!(DeliveryRepresentation::Packet as u8, 0x01); + assert_eq!(DeliveryRepresentation::Resource as u8, 0x02); + assert_eq!(DeliveryRepresentation::Paper as u8, 0x05); + } + + #[test] + fn test_propagation_retrieval_states() { + assert_eq!(PropagationRetrievalState::Idle as u8, 0x00); + assert_eq!(PropagationRetrievalState::Complete as u8, 0x07); + assert_eq!(PropagationRetrievalState::NoPath as u8, 0xF0); + assert_eq!(PropagationRetrievalState::Failed as u8, 0xFE); + // Ordering must support range comparisons. + assert!(PropagationRetrievalState::Idle < PropagationRetrievalState::LinkEstablished); + assert!(PropagationRetrievalState::Complete < PropagationRetrievalState::NoPath); + } + + #[test] + fn test_router_constants_match_python() { + assert_eq!(PROCESSING_INTERVAL, 4); + assert_eq!(LINK_MAX_INACTIVITY, 600); + assert_eq!(P_LINK_MAX_INACTIVITY, 180); + assert_eq!(NODE_ANNOUNCE_DELAY, 20); + assert_eq!(SYNC_LIMIT, 10240); + assert_eq!(PROPAGATION_COST_MIN, 13); + assert_eq!(MAX_PEERING_COST, 26); + assert_eq!(AUTOPEER_MAXDEPTH, 4); + assert_eq!(FASTEST_N_RANDOM_POOL, 2); + } + + #[test] + fn test_stamp_expand_rounds_match_python() { + assert_eq!(STAMP_WORKBLOCK_EXPAND_ROUNDS, 3000); + assert_eq!(STAMP_WORKBLOCK_EXPAND_ROUNDS_PN, 1000); + assert_eq!(STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING, 25); + assert_eq!(PN_VALIDATION_POOL_MIN_SIZE, 256); + } + + #[test] + fn test_peer_constants_match_python() { + assert_eq!(MAX_UNREACHABLE, 14 * 24 * 60 * 60); + assert_eq!(SYNC_BACKOFF_STEP, 12 * 60); + } +} diff --git a/crates/lxmf-core/src/discovery_stamper.rs b/crates/lxmf-core/src/discovery_stamper.rs new file mode 100644 index 0000000..4907067 --- /dev/null +++ b/crates/lxmf-core/src/discovery_stamper.rs @@ -0,0 +1,236 @@ +//! LXMF-backed stamper for Reticulum interface discovery. +//! +//! # Layering +//! +//! Python `RNS/Discovery.py:41` imports `LXMF.LXStamper` directly; the +//! discovery subsystem cannot work without LXMF installed. Rust keeps +//! Reticulum below LXMF, so `rns-transport` depends on a trait object and +//! the concrete implementation lives here. +//! +//! # Workblock: Python parity +//! +//! Python discovery uses `LXStamper.stamp_workblock(infohash, expand_rounds=20)` +//! (RNS/Discovery.py:220), where `stamp_workblock` is the +//! HKDF-expanded construction: one HKDF expand per round, each round +//! producing 256 bytes, total `expand_rounds * 256` bytes. +//! +//! The matching Rust primitive is [`crate::stamper::stamp_workblock_raw`] +//! (the plain `stamp_workblock` in `lxmf-core` uses an iterative +//! SHA-256 workblock for a *different* path and is **not** wire +//! compatible with Python's discovery stamps). +//! +use rns_transport::discovery::DiscoveryStamper; + +use crate::stamper::{stamp_valid_raw, stamp_value_raw, stamp_workblock_raw}; + +/// Python `RNS.Discovery.InterfaceAnnouncer.WORKBLOCK_EXPAND_ROUNDS`, +/// the expand-round count discovery uses when building its workblock. +/// +/// Much smaller than message stamps: discovery stamps are refreshed per +/// interface, so this path has to stay cheap enough for periodic announces. +pub const DISCOVERY_WORKBLOCK_EXPAND_ROUNDS: usize = 20; + +/// Upper bound on the random-stamp search before we give up on a given +/// tick. If the cap is hit, the announcer skips this cycle and tries again +/// rather than pinning a blocking worker indefinitely. +/// +/// Python has no equivalent cap (it blocks until success); we cap so +/// `spawn_blocking` threads cannot be stuck forever if the user +/// misconfigures `discover_interfaces_required_value` to something +/// unreasonable. +pub const DISCOVERY_MAX_ITERATIONS: u64 = 5_000_000; + +/// PoW stamper for on-network discovery announces. Thin wrapper around +/// [`lxmf_core::stamper`](crate::stamper) that binds the exact Python +/// discovery construction (HKDF workblock with 20 expand rounds). +/// +/// Clonable and `Send + Sync`; the default instance is fine for most +/// users. +#[derive(Debug, Clone, Default)] +pub struct LxmfDiscoveryStamper { + /// Override the iteration cap; defaults to [`DISCOVERY_MAX_ITERATIONS`]. + /// Zero means "use the default". + max_iterations: u64, +} + +impl LxmfDiscoveryStamper { + /// Build a stamper with a custom iteration cap. Most callers want + /// [`LxmfDiscoveryStamper::default`]. + pub fn with_max_iterations(max_iterations: u64) -> Self { + Self { max_iterations } + } + + fn effective_max_iterations(&self) -> u64 { + if self.max_iterations == 0 { + DISCOVERY_MAX_ITERATIONS + } else { + self.max_iterations + } + } +} + +impl DiscoveryStamper for LxmfDiscoveryStamper { + fn generate(&self, infohash: &[u8; 32], target_value: u8) -> Option> { + if target_value == 0 { + return Some(vec![0u8; 32]); + } + + let workblock = stamp_workblock_raw(infohash, DISCOVERY_WORKBLOCK_EXPAND_ROUNDS); + + for _ in 0..self.effective_max_iterations() { + let candidate = crate::stamper::rand_bytes(); + if stamp_valid_raw(&candidate, target_value, &workblock) { + return Some(candidate.to_vec()); + } + } + None + } + + fn value(&self, infohash: &[u8; 32], stamp: &[u8]) -> u8 { + if stamp.len() != 32 { + return 0; + } + let mut stamp_arr = [0u8; 32]; + stamp_arr.copy_from_slice(stamp); + let workblock = stamp_workblock_raw(infohash, DISCOVERY_WORKBLOCK_EXPAND_ROUNDS); + let v = stamp_value_raw(&workblock, &stamp_arr); + v.min(u8::MAX as u32) as u8 + } + + fn valid(&self, infohash: &[u8; 32], stamp: &[u8], required_value: u8) -> bool { + if required_value == 0 { + return true; + } + if stamp.len() != 32 { + return false; + } + let mut stamp_arr = [0u8; 32]; + stamp_arr.copy_from_slice(stamp); + let workblock = stamp_workblock_raw(infohash, DISCOVERY_WORKBLOCK_EXPAND_ROUNDS); + stamp_valid_raw(&stamp_arr, required_value, &workblock) + } +} + +/// Validation helper: synchronous wrapper mirroring +/// [`crate::stamper::generate_stamp_limited`] but using the HKDF +/// workblock construction. Public so interop tests and downstream validation +/// can exercise discovery stamping without a transport runtime. +pub fn generate_discovery_stamp( + infohash: &[u8; 32], + target_value: u8, + max_iterations: u64, +) -> Option<[u8; 32]> { + if target_value == 0 { + return Some([0u8; 32]); + } + let workblock = stamp_workblock_raw(infohash, DISCOVERY_WORKBLOCK_EXPAND_ROUNDS); + for _ in 0..max_iterations { + let candidate = crate::stamper::rand_bytes(); + if stamp_valid_raw(&candidate, target_value, &workblock) { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use rns_crypto::sha::sha256; + + fn mk_infohash(seed: &[u8]) -> [u8; 32] { + sha256(seed) + } + + #[test] + fn cost_zero_generates_immediately_and_is_always_valid() { + let stamper = LxmfDiscoveryStamper::default(); + let infohash = mk_infohash(b"cost-zero"); + let stamp = stamper.generate(&infohash, 0).unwrap(); + assert_eq!(stamp.len(), 32); + assert!(stamper.valid(&infohash, &stamp, 0)); + } + + #[test] + fn generate_stamp_passes_valid() { + let stamper = LxmfDiscoveryStamper::with_max_iterations(200_000); + let infohash = mk_infohash(b"generate-round-trip"); + let cost = 6; + let stamp = stamper.generate(&infohash, cost); + assert!(stamp.is_some(), "cost={cost} should be findable within cap"); + assert!(stamper.valid(&infohash, &stamp.unwrap(), cost)); + } + + #[test] + fn value_reports_leading_zero_bits() { + let stamper = LxmfDiscoveryStamper::with_max_iterations(200_000); + let infohash = mk_infohash(b"value-check"); + let cost = 4; + let stamp = stamper.generate(&infohash, cost).unwrap(); + let value = stamper.value(&infohash, &stamp); + assert!(value >= cost, "value {value} must be >= cost {cost}"); + } + + #[test] + fn invalid_stamp_is_rejected() { + let stamper = LxmfDiscoveryStamper::default(); + let infohash = mk_infohash(b"invalid"); + let bogus = [0xFFu8; 32]; + assert!(!stamper.valid(&infohash, &bogus, 32)); + } + + #[test] + fn non_32_byte_stamp_is_rejected() { + let stamper = LxmfDiscoveryStamper::default(); + let infohash = mk_infohash(b"wrong-size"); + // Non-standard length; must not panic, must not validate. + assert_eq!(stamper.value(&infohash, &[0u8; 16]), 0); + assert!(!stamper.valid(&infohash, &[0u8; 16], 8)); + } + + #[test] + fn generate_gives_up_when_cap_exhausted() { + // Cost 64 is astronomically unreachable in a handful of iters. + let stamper = LxmfDiscoveryStamper::with_max_iterations(10); + let infohash = mk_infohash(b"unreachable"); + assert!(stamper.generate(&infohash, 64).is_none()); + } + + #[test] + fn two_generated_stamps_both_validate_independently() { + let stamper = LxmfDiscoveryStamper::with_max_iterations(200_000); + let a = mk_infohash(b"a"); + let b = mk_infohash(b"b"); + let cost = 4; + let sa = stamper.generate(&a, cost).unwrap(); + let sb = stamper.generate(&b, cost).unwrap(); + assert!(stamper.valid(&a, &sa, cost)); + assert!(stamper.valid(&b, &sb, cost)); + // Cross-validation MUST fail (different workblocks). + assert!( + !stamper.valid(&a, &sb, 16) || stamper.value(&a, &sb) < 16, + "cross-infohash stamp must not clear a meaningful cost" + ); + } + + #[test] + fn workblock_constant_matches_python() { + assert_eq!(DISCOVERY_WORKBLOCK_EXPAND_ROUNDS, 20); + } + + #[test] + fn generate_discovery_stamp_helper_works() { + let infohash = mk_infohash(b"helper"); + let stamp = generate_discovery_stamp(&infohash, 4, 200_000); + assert!(stamp.is_some()); + let stamper = LxmfDiscoveryStamper::default(); + assert!(stamper.valid(&infohash, &stamp.unwrap(), 4)); + } + + #[test] + fn generate_discovery_stamp_cost_zero_is_instant() { + let infohash = mk_infohash(b"helper-zero"); + let stamp = generate_discovery_stamp(&infohash, 0, 1); + assert_eq!(stamp, Some([0u8; 32])); + } +} diff --git a/crates/lxmf-core/src/handlers.rs b/crates/lxmf-core/src/handlers.rs new file mode 100644 index 0000000..42fe4e1 --- /dev/null +++ b/crates/lxmf-core/src/handlers.rs @@ -0,0 +1,1123 @@ +//! Announce handlers for `lxmf.delivery` and `lxmf.propagation` destinations, plus the +//! propagation-node control endpoint dispatcher. +//! +//! Python reference: LXMF/Handlers.py, LXMF.py:172-198, LXMRouter.py:306-318, LXMRouter.py:985-1000. + +use std::collections::HashMap; + +use crate::constants::*; + +/// Announce handler type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandlerType { + Delivery, + Propagation, +} + +impl HandlerType { + /// Aspect filter for matching RNS destinations. + pub fn aspect_filter(&self) -> &'static str { + match self { + HandlerType::Delivery => DELIVERY_ASPECT, + HandlerType::Propagation => PROPAGATION_ASPECT, + } + } + + /// Fully-qualified `app_name.aspect` string. + pub fn full_aspect(&self) -> String { + format!("{}.{}", APP_NAME, self.aspect_filter()) + } +} + +/// Outcome of processing an announce. +#[derive(Debug)] +pub enum AnnounceResult { + Accepted, + Ignored, + Rejected(String), +} + +/// Parsed propagation-node announce data. +/// +/// Wire layout is a 7-element msgpack array: +/// `[false, timebase, node_state, transfer_limit_kb, sync_limit_kb, +/// [stamp_cost, stamp_flex, peering_cost], metadata]`. +/// +/// Python reference: LXMRouter.py:306-318. +#[derive(Debug, Clone)] +pub struct PropagationNodeAnnounceData { + /// Legacy LXMF PN-support flag, always false. + pub legacy: bool, + /// Node timebase as Unix seconds. + pub timebase: i64, + /// True when the node is actively serving propagation. + pub node_state: bool, + /// Per-transfer limit in kilobytes. + pub transfer_limit: u64, + /// Per-sync limit in kilobytes. + pub sync_limit: u64, + pub stamp_cost: u8, + pub stamp_flex: u8, + pub peering_cost: u8, + pub metadata: HashMap>, +} + +impl PropagationNodeAnnounceData { + pub fn new( + node_state: bool, + transfer_limit: u64, + sync_limit: u64, + stamp_cost: u8, + stamp_flex: u8, + peering_cost: u8, + ) -> Self { + Self { + legacy: false, + timebase: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + node_state, + transfer_limit, + sync_limit, + stamp_cost, + stamp_flex, + peering_cost, + metadata: HashMap::new(), + } + } + + pub fn set_name(&mut self, name: &str) { + self.metadata.insert(PN_META_NAME, name.as_bytes().to_vec()); + } +} + +/// Encode propagation-node announce `app_data` as msgpack. +/// +/// Python reference: LXMRouter.py:306-318. +pub fn get_propagation_node_app_data(data: &PropagationNodeAnnounceData) -> Vec { + use rmpv::Value; + + let stamp_costs = Value::Array(vec![ + Value::from(data.stamp_cost as u64), + Value::from(data.stamp_flex as u64), + Value::from(data.peering_cost as u64), + ]); + + let metadata = { + let mut map = Vec::new(); + for (k, v) in &data.metadata { + map.push((Value::from(*k as u64), Value::Binary(v.clone()))); + } + Value::Map(map) + }; + + let announce_data = Value::Array(vec![ + Value::Boolean(false), + Value::from(data.timebase), + Value::Boolean(data.node_state), + Value::from(data.transfer_limit), + Value::from(data.sync_limit), + stamp_costs, + metadata, + ]); + + crate::encode_value(&announce_data) +} + +/// Parse propagation-node announce data. Returns `None` if the data is +/// malformed (not msgpack, not an array, fewer than 7 elements, or any +/// element of the wrong type) — same rejection criteria as Python's +/// `pn_announce_data_is_valid` at `LXMF.py:172-198`. The validator and +/// the parser used to be two separate functions; the validator was +/// dropped because it duplicated this function's checks and every +/// in-tree caller called both back-to-back. +pub fn parse_pn_announce_data(data: &[u8]) -> Option { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).ok()?; + let arr = value.as_array()?; + if arr.len() < 7 { + return None; + } + + let timebase = arr[1] + .as_i64() + .or_else(|| arr[1].as_u64().map(|u| u as i64)) + .or_else(|| arr[1].as_f64().map(|f| f as i64))?; + let node_state = arr[2].as_bool()?; + let transfer_limit = arr[3] + .as_u64() + .or_else(|| arr[3].as_i64().map(|i| i as u64)) + .or_else(|| arr[3].as_f64().map(|f| f as u64))?; + let sync_limit = arr[4] + .as_u64() + .or_else(|| arr[4].as_i64().map(|i| i as u64)) + .or_else(|| arr[4].as_f64().map(|f| f as u64))?; + + let costs = arr[5].as_array()?; + if costs.len() < 3 { + return None; + } + let stamp_cost = costs[0] + .as_u64() + .or_else(|| costs[0].as_i64().map(|i| i as u64))? as u8; + let stamp_flex = costs[1] + .as_u64() + .or_else(|| costs[1].as_i64().map(|i| i as u64))? as u8; + let peering_cost = costs[2] + .as_u64() + .or_else(|| costs[2].as_i64().map(|i| i as u64))? as u8; + + let mut metadata = HashMap::new(); + if let Some(map) = arr[6].as_map() { + for (k, v) in map { + if let (Some(key), Some(val)) = ( + k.as_u64().map(|u| u as u8), + v.as_slice().map(|s| s.to_vec()), + ) { + metadata.insert(key, val); + } + } + } + + Some(PropagationNodeAnnounceData { + legacy: false, + timebase, + node_state, + transfer_limit, + sync_limit, + stamp_cost, + stamp_flex, + peering_cost, + metadata, + }) +} + +/// Extract stamp cost from propagation-node announce data. +/// +/// Python reference: `pn_stamp_cost_from_app_data` — LXMF.py:163-170. +pub fn pn_stamp_cost_from_app_data(data: &[u8]) -> Option { + parse_pn_announce_data(data).map(|p| p.stamp_cost) +} + +/// Encode delivery-announce `app_data` as msgpack `[display_name, stamp_cost]`. +/// +/// `stamp_cost` must be in `1..=254`; out-of-range or `None` values are encoded as nil. +/// +/// Python reference: `get_announce_app_data` — LXMRouter.py:985-1000. +pub fn get_announce_app_data(display_name: Option<&str>, stamp_cost: Option) -> Vec { + use rmpv::Value; + + let name_val = match display_name { + Some(name) => Value::Binary(name.as_bytes().to_vec()), + None => Value::Nil, + }; + + let cost_val = match stamp_cost { + Some(cost) if cost > 0 && cost < 255 => Value::from(cost as u64), + _ => Value::Nil, + }; + + let peer_data = Value::Array(vec![name_val, cost_val]); + + crate::encode_value(&peer_data) +} + +/// Parse delivery-announce `app_data`, returning `(display_name, stamp_cost)`. +/// +/// Accepts both Python LXMF 0.9.6's 2-element form and the optional 3-element +/// feature-list form used by newer/extended peers. +pub fn parse_announce_app_data(data: &[u8]) -> Option<(Option, Option)> { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).ok()?; + let arr = value.as_array()?; + if arr.len() < 2 { + return None; + } + + let display_name = arr[0] + .as_slice() + .and_then(|b| String::from_utf8(b.to_vec()).ok()); + + let stamp_cost = arr[1].as_u64().map(|c| c as u8); + + Some((display_name, stamp_cost)) +} + +/// Extract just the display name from delivery-announce `app_data`. +/// +/// Python reference: `display_name_from_app_data` — LXMF.py:131-143. +pub fn display_name_from_app_data(data: &[u8]) -> Option { + parse_announce_app_data(data).and_then(|(name, _)| name) +} + +/// Extract just the stamp cost from delivery-announce `app_data`. +/// +/// Python reference: `stamp_cost_from_app_data` — LXMF.py:145-152. +pub fn stamp_cost_from_app_data(data: &[u8]) -> Option { + parse_announce_app_data(data).and_then(|(_, cost)| cost) +} + +/// Check whether a peer advertises `SF_COMPRESSION` support in its delivery-announce `app_data`. +/// +/// Returns `false` for legacy 2-element app_data or when the feature list is missing/empty. +/// +/// Python reference: `compression_support_from_app_data` — LXMF.py:154-164. +pub fn compression_support_from_app_data(data: &[u8]) -> bool { + let Ok(value) = rmpv::decode::read_value(&mut &data[..]) else { + return false; + }; + let Some(arr) = value.as_array() else { + return false; + }; + if arr.len() < 3 { + return false; + } + let Some(features) = arr[2].as_array() else { + return false; + }; + features + .iter() + .any(|f| f.as_u64() == Some(crate::constants::SF_COMPRESSION as u64)) +} + +/// Extract the advertised name from propagation-node announce data. +/// +/// Reads the [`PN_META_NAME`] metadata entry and decodes it as UTF-8. +/// +/// Python reference: `pn_name_from_app_data` — LXMF.py:172-181. +pub fn pn_name_from_app_data(data: &[u8]) -> Option { + let parsed = parse_pn_announce_data(data)?; + let name_bytes = parsed.metadata.get(&crate::constants::PN_META_NAME)?; + String::from_utf8(name_bytes.clone()).ok() +} + +/// Outcome of a resource transfer, matching Python `resource_concluded` / +/// `propagation_resource_concluded` callbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceResult { + Complete, + Rejected, + Failed, +} + +/// Propagation-node control endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlEndpoint { + Stats, + PeerSync, + PeerUnpeer, +} + +impl ControlEndpoint { + pub fn path(&self) -> &'static str { + match self { + ControlEndpoint::Stats => STATS_GET_PATH, + ControlEndpoint::PeerSync => SYNC_REQUEST_PATH, + ControlEndpoint::PeerUnpeer => UNPEER_REQUEST_PATH, + } + } +} + +/// Outcome of a control-endpoint request. +#[derive(Debug)] +pub enum ControlResult { + Success(Vec), + NoIdentity, + NoAccess, + InvalidData, + NotFound, +} + +/// Propagation-node request handler. +/// +/// Owns the access-control state and dispatches the three request paths (`/pn/get/stats`, +/// `/offer`, `/get`) to [`crate::propagation_node::PropagationNode`]. Python reference: +/// LXMRouter.py:650-657. +pub struct PropagationRequestHandler { + pub control_allowed: Vec<[u8; 16]>, + /// When true, only peers in [`static_peers`](Self::static_peers) may submit offers. + pub from_static_only: bool, + pub static_peers: std::collections::HashSet<[u8; 16]>, + /// Throttled peers mapped to their expiry Unix timestamp. + pub throttled_peers: HashMap<[u8; 16], f64>, +} + +impl PropagationRequestHandler { + /// Create a handler seeded with the local identity hash, which is always control-allowed. + pub fn new(identity_hash: [u8; 16]) -> Self { + Self { + control_allowed: vec![identity_hash], + from_static_only: false, + static_peers: std::collections::HashSet::new(), + throttled_peers: HashMap::new(), + } + } + + /// Handle a `/pn/get/stats` request: validates identity and access, then returns msgpack stats. + /// + /// Python reference: `LXMRouter.stats_get_request` — LXMRouter.py:819-822. + pub fn handle_stats_request( + &self, + remote_identity_hash: Option<&[u8; 16]>, + node: &crate::propagation_node::PropagationNode, + peers: &HashMap<[u8; 16], crate::peer::LxmPeer>, + ) -> ControlResult { + let identity_hash = match remote_identity_hash { + Some(h) => h, + None => return ControlResult::NoIdentity, + }; + + if !self.control_allowed.contains(identity_hash) { + return ControlResult::NoAccess; + } + + let stats = self.compile_stats(node, peers); + ControlResult::Success(stats) + } + + /// Handle an `/offer` request from a syncing peer. + /// + /// Validates identity, throttle status, access, and peering key, then delegates to + /// [`crate::propagation_node::PropagationNode::handle_offer_request`]. Python reference: + /// `LXMRouter.offer_request` — LXMRouter.py:2139-2189. + pub fn handle_offer_request( + &self, + remote_identity_hash: Option<&[u8; 16]>, + request_data: &[u8], + node: &mut crate::propagation_node::PropagationNode, + ) -> Vec { + let identity_known = remote_identity_hash.is_some(); + + let is_throttled = remote_identity_hash + .map(|h| { + self.throttled_peers + .get(h) + .map(|expiry| now_f64() < *expiry) + .unwrap_or(false) + }) + .unwrap_or(false); + + let access_allowed = if self.from_static_only { + remote_identity_hash + .map(|h| self.static_peers.contains(h)) + .unwrap_or(false) + } else { + true + }; + + let peer_hash = remote_identity_hash.copied().unwrap_or([0u8; 16]); + + node.handle_offer_request( + request_data, + peer_hash, + identity_known, + is_throttled, + access_allowed, + remote_identity_hash, + ) + } + + /// Handle a `/get` request from a client downloading messages. + /// + /// Python reference: `LXMRouter.message_get_request` — LXMRouter.py:1425-1499. + pub fn handle_message_get_request( + &self, + remote_identity_hash: Option<&[u8; 16]>, + client_dest_hash: &[u8; 16], + request_data: &[u8], + node: &mut crate::propagation_node::PropagationNode, + ) -> Vec { + use rmpv::Value; + + let _identity_hash = match remote_identity_hash { + Some(h) => h, + None => { + let error = Value::from(PeerError::NoIdentity as u64); + return crate::encode_value(&error); + } + }; + + node.handle_get_request(request_data, client_dest_hash) + } + + /// Handle a `/pn/peer/sync` request. Returns msgpack `true` on success; the caller performs + /// the actual sync trigger. + /// + /// Python reference: `LXMRouter.peer_sync_request` — LXMRouter.py:824-834. + pub fn handle_peer_sync_request( + &self, + remote_identity_hash: Option<&[u8; 16]>, + request_data: &[u8], + peers: &HashMap<[u8; 16], crate::peer::LxmPeer>, + ) -> ControlResult { + let identity_hash = match remote_identity_hash { + Some(h) => h, + None => return ControlResult::NoIdentity, + }; + + if !self.control_allowed.contains(identity_hash) { + return ControlResult::NoAccess; + } + + if request_data.len() != 16 { + return ControlResult::InvalidData; + } + + let mut peer_hash = [0u8; 16]; + peer_hash.copy_from_slice(request_data); + + if !peers.contains_key(&peer_hash) { + return ControlResult::NotFound; + } + + ControlResult::Success(crate::encode_value(&rmpv::Value::Boolean(true))) + } + + /// Handle a `/pn/peer/unpeer` request. Returns msgpack `true` on success; the caller performs + /// the actual peer removal. + /// + /// Python reference: `LXMRouter.peer_unpeer_request` — LXMRouter.py:836-846. + pub fn handle_peer_unpeer_request( + &self, + remote_identity_hash: Option<&[u8; 16]>, + request_data: &[u8], + peers: &HashMap<[u8; 16], crate::peer::LxmPeer>, + ) -> ControlResult { + let identity_hash = match remote_identity_hash { + Some(h) => h, + None => return ControlResult::NoIdentity, + }; + + if !self.control_allowed.contains(identity_hash) { + return ControlResult::NoAccess; + } + + if request_data.len() != 16 { + return ControlResult::InvalidData; + } + + let mut peer_hash = [0u8; 16]; + peer_hash.copy_from_slice(request_data); + + if !peers.contains_key(&peer_hash) { + return ControlResult::NotFound; + } + + ControlResult::Success(crate::encode_value(&rmpv::Value::Boolean(true))) + } + + pub fn cleanup_throttled_peers(&mut self) { + let now = now_f64(); + self.throttled_peers.retain(|_, expiry| *expiry > now); + } + + /// Throttle `peer_hash` for `duration_secs` starting now. + /// + /// Python reference: `LXMRouter.propagation_resource_concluded` — LXMRouter.py:2277-2278. + pub fn throttle_peer(&mut self, peer_hash: [u8; 16], duration_secs: f64) { + let expiry = now_f64() + duration_secs; + self.throttled_peers.insert(peer_hash, expiry); + } + + pub fn is_peer_throttled(&self, peer_hash: &[u8; 16]) -> bool { + self.throttled_peers + .get(peer_hash) + .map(|expiry| now_f64() < *expiry) + .unwrap_or(false) + } + + pub fn allow_control(&mut self, identity_hash: [u8; 16]) { + if !self.control_allowed.contains(&identity_hash) { + self.control_allowed.push(identity_hash); + } + } + + pub fn disallow_control(&mut self, identity_hash: &[u8; 16]) { + self.control_allowed.retain(|h| h != identity_hash); + } + + /// Compile propagation-node statistics as msgpack. + /// + /// Python reference: `LXMRouter.compile_stats` — LXMRouter.py:750-817. + fn compile_stats( + &self, + node: &crate::propagation_node::PropagationNode, + peers: &HashMap<[u8; 16], crate::peer::LxmPeer>, + ) -> Vec { + use rmpv::Value; + + let mut peer_entries = Vec::new(); + for (hash, peer) in peers { + let peer_map = Value::Map(vec![ + ( + Value::String("state".into()), + Value::from(peer.state as u64), + ), + (Value::String("alive".into()), Value::Boolean(peer.alive)), + ( + Value::String("last_heard".into()), + Value::from(peer.last_heard as i64), + ), + ( + Value::String("str".into()), + Value::from(peer.sync_transfer_rate as i64), + ), + (Value::String("rx_bytes".into()), Value::from(peer.rx_bytes)), + (Value::String("tx_bytes".into()), Value::from(peer.tx_bytes)), + (Value::String("offered".into()), Value::from(peer.offered)), + (Value::String("outgoing".into()), Value::from(peer.outgoing)), + (Value::String("incoming".into()), Value::from(peer.incoming)), + ( + Value::String("unhandled".into()), + Value::from(peer.unhandled_messages() as u64), + ), + ]); + peer_entries.push((Value::Binary(hash.to_vec()), peer_map)); + } + + let stats = Value::Map(vec![ + ( + Value::String("destination_hash".into()), + Value::Binary(node.dest_hash.to_vec()), + ), + ( + Value::String("message_count".into()), + Value::from(node.message_count() as u64), + ), + ( + Value::String("message_size".into()), + Value::from(node.total_size() as u64), + ), + ( + Value::String("total_peers".into()), + Value::from(peers.len() as u64), + ), + (Value::String("peers".into()), Value::Map(peer_entries)), + ]); + + crate::encode_value(&stats) + } +} + +fn now_f64() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_handler_aspects() { + assert_eq!(HandlerType::Delivery.aspect_filter(), "delivery"); + assert_eq!(HandlerType::Propagation.aspect_filter(), "propagation"); + assert_eq!(HandlerType::Delivery.full_aspect(), "lxmf.delivery"); + assert_eq!(HandlerType::Propagation.full_aspect(), "lxmf.propagation"); + } + + #[test] + fn test_pn_announce_data_roundtrip() { + let mut data = PropagationNodeAnnounceData::new( + true, + PROPAGATION_LIMIT as u64, + SYNC_LIMIT as u64, + PROPAGATION_COST, + PROPAGATION_COST_FLEX, + PEERING_COST, + ); + data.set_name("TestNode"); + + let packed = get_propagation_node_app_data(&data); + let parsed = parse_pn_announce_data(&packed).unwrap(); + assert!(!parsed.legacy); + assert!(parsed.node_state); + assert_eq!(parsed.transfer_limit, PROPAGATION_LIMIT as u64); + assert_eq!(parsed.sync_limit, SYNC_LIMIT as u64); + assert_eq!(parsed.stamp_cost, PROPAGATION_COST); + assert_eq!(parsed.stamp_flex, PROPAGATION_COST_FLEX); + assert_eq!(parsed.peering_cost, PEERING_COST); + assert_eq!( + parsed.metadata.get(&PN_META_NAME), + Some(&b"TestNode".to_vec()) + ); + } + + #[test] + fn test_pn_announce_data_accepts_python_float_fields() { + use rmpv::Value; + + let value = Value::Array(vec![ + Value::Boolean(false), + Value::F64(1_777_716_440.197976), + Value::Boolean(true), + Value::F64(10240.0), + Value::F64(10240.0), + Value::Array(vec![Value::from(16), Value::from(3), Value::from(18)]), + Value::Map(vec![]), + ]); + let mut packed = Vec::new(); + rmpv::encode::write_value(&mut packed, &value).unwrap(); + + let parsed = parse_pn_announce_data(&packed).unwrap(); + assert!(parsed.node_state); + assert_eq!(parsed.transfer_limit, 10240); + assert_eq!(parsed.sync_limit, 10240); + assert_eq!(parsed.stamp_cost, 16); + } + + #[test] + fn test_pn_announce_data_invalid() { + // Empty bytes, 1-element array, single int — all rejected by the + // parser (same rejection set as Python's pn_announce_data_is_valid). + assert!(parse_pn_announce_data(&[]).is_none()); + assert!(parse_pn_announce_data(&[0x91, 0xC2]).is_none()); + assert!(parse_pn_announce_data(&[0x01]).is_none()); + } + + #[test] + fn test_pn_stamp_cost_extraction() { + let data = PropagationNodeAnnounceData::new( + true, + PROPAGATION_LIMIT as u64, + SYNC_LIMIT as u64, + 20, + 3, + 18, + ); + let packed = get_propagation_node_app_data(&data); + assert_eq!(pn_stamp_cost_from_app_data(&packed), Some(20)); + } + + #[test] + fn test_delivery_announce_data_roundtrip() { + let packed = get_announce_app_data(Some("Alice"), Some(12)); + let (name, cost) = parse_announce_app_data(&packed).unwrap(); + assert_eq!(name, Some("Alice".to_string())); + assert_eq!(cost, Some(12)); + } + + #[test] + fn test_delivery_announce_data_no_name() { + let packed = get_announce_app_data(None, Some(8)); + let (name, cost) = parse_announce_app_data(&packed).unwrap(); + assert!(name.is_none()); + assert_eq!(cost, Some(8)); + } + + #[test] + fn test_delivery_announce_data_no_cost() { + let packed = get_announce_app_data(Some("Bob"), None); + let (name, cost) = parse_announce_app_data(&packed).unwrap(); + assert_eq!(name, Some("Bob".to_string())); + assert!(cost.is_none()); + } + + #[test] + fn test_delivery_announce_data_empty() { + let packed = get_announce_app_data(None, None); + let (name, cost) = parse_announce_app_data(&packed).unwrap(); + assert!(name.is_none()); + assert!(cost.is_none()); + } + + #[test] + fn test_delivery_announce_data_cost_zero_treated_as_none() { + let packed = get_announce_app_data(Some("Test"), Some(0)); + let (name, cost) = parse_announce_app_data(&packed).unwrap(); + assert_eq!(name, Some("Test".to_string())); + assert!(cost.is_none()); + } + + #[test] + fn test_display_name_from_app_data() { + let packed = get_announce_app_data(Some("Alice"), Some(12)); + assert_eq!( + display_name_from_app_data(&packed), + Some("Alice".to_string()) + ); + + let packed = get_announce_app_data(None, Some(12)); + assert_eq!(display_name_from_app_data(&packed), None); + } + + #[test] + fn test_stamp_cost_from_app_data() { + let packed = get_announce_app_data(Some("Alice"), Some(12)); + assert_eq!(stamp_cost_from_app_data(&packed), Some(12)); + + let packed = get_announce_app_data(Some("Alice"), None); + assert_eq!(stamp_cost_from_app_data(&packed), None); + } + + #[test] + fn test_compression_support_from_app_data() { + let python_096 = get_announce_app_data(Some("Alice"), Some(12)); + assert!(!compression_support_from_app_data(&python_096)); + + let supported = { + use rmpv::Value; + let arr = Value::Array(vec![ + Value::Binary(b"Alice".to_vec()), + Value::from(12u64), + Value::Array(vec![Value::from(crate::constants::SF_COMPRESSION as u64)]), + ]); + crate::encode_value(&arr) + }; + assert!(compression_support_from_app_data(&supported)); + + // 3-element form with empty feature list -> unsupported. + let empty_features = { + use rmpv::Value; + let arr = Value::Array(vec![ + Value::Binary(b"Alice".to_vec()), + Value::from(12u64), + Value::Array(vec![]), + ]); + crate::encode_value(&arr) + }; + assert!(!compression_support_from_app_data(&empty_features)); + } + + #[test] + fn test_pn_name_from_app_data() { + let mut data = PropagationNodeAnnounceData::new( + true, + PROPAGATION_LIMIT as u64, + SYNC_LIMIT as u64, + 20, + 3, + 18, + ); + data.metadata + .insert(crate::constants::PN_META_NAME, b"HubNode".to_vec()); + let packed = get_propagation_node_app_data(&data); + assert_eq!(pn_name_from_app_data(&packed), Some("HubNode".to_string())); + } + + #[test] + fn test_pn_node_state_false() { + let data = PropagationNodeAnnounceData::new(false, 256, 10240, 16, 3, 18); + let packed = get_propagation_node_app_data(&data); + let parsed = parse_pn_announce_data(&packed).unwrap(); + assert!(!parsed.node_state); + } + + #[test] + fn test_pn_empty_metadata() { + let data = PropagationNodeAnnounceData::new(true, 256, 10240, 16, 3, 18); + let packed = get_propagation_node_app_data(&data); + let parsed = parse_pn_announce_data(&packed).unwrap(); + assert!(parsed.metadata.is_empty()); + } + + #[test] + fn test_resource_result() { + assert_ne!(ResourceResult::Complete, ResourceResult::Rejected); + assert_ne!(ResourceResult::Complete, ResourceResult::Failed); + } + + #[test] + fn test_control_endpoint_paths() { + assert_eq!(ControlEndpoint::Stats.path(), "/pn/get/stats"); + assert_eq!(ControlEndpoint::PeerSync.path(), "/pn/peer/sync"); + assert_eq!(ControlEndpoint::PeerUnpeer.path(), "/pn/peer/unpeer"); + } + + #[test] + fn test_propagation_handler_creation() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + assert_eq!(handler.control_allowed.len(), 1); + assert_eq!(handler.control_allowed[0], [0xAA; 16]); + assert!(!handler.from_static_only); + assert!(handler.static_peers.is_empty()); + assert!(handler.throttled_peers.is_empty()); + } + + #[test] + fn test_stats_request_no_identity() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let peers = HashMap::new(); + + let result = handler.handle_stats_request(None, &node, &peers); + assert!(matches!(result, ControlResult::NoIdentity)); + } + + #[test] + fn test_stats_request_no_access() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let peers = HashMap::new(); + let unauthorized = [0xCC; 16]; + + let result = handler.handle_stats_request(Some(&unauthorized), &node, &peers); + assert!(matches!(result, ControlResult::NoAccess)); + } + + #[test] + fn test_stats_request_success() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let peers = HashMap::new(); + + let result = handler.handle_stats_request(Some(&identity), &node, &peers); + match result { + ControlResult::Success(data) => { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).unwrap(); + assert!(value.is_map()); + } + _ => panic!("expected Success"), + } + } + + #[test] + fn test_offer_request_handler_no_identity() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let mut node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + + let offer_data = { + use rmpv::Value; + let arr = Value::Array(vec![Value::Binary(vec![]), Value::Array(vec![])]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &arr).unwrap(); + buf + }; + + let response = handler.handle_offer_request(None, &offer_data, &mut node); + let value: rmpv::Value = rmpv::decode::read_value(&mut &response[..]).unwrap(); + assert_eq!(value.as_u64(), Some(PeerError::NoIdentity as u64)); + } + + #[test] + fn test_offer_request_handler_success() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let mut node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let peer_hash = [0xCC; 16]; + + let offer_data = { + use rmpv::Value; + let arr = Value::Array(vec![Value::Binary(vec![]), Value::Array(vec![])]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &arr).unwrap(); + buf + }; + + let response = handler.handle_offer_request(Some(&peer_hash), &offer_data, &mut node); + // Empty offer against empty store -> HaveAll (false). + let value: rmpv::Value = rmpv::decode::read_value(&mut &response[..]).unwrap(); + assert_eq!(value.as_bool(), Some(false)); + } + + #[test] + fn test_message_get_request_no_identity() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let mut node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let client_dest = [0xCC; 16]; + + let request_data = { + use rmpv::Value; + let arr = Value::Array(vec![Value::Nil, Value::Nil]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &arr).unwrap(); + buf + }; + + let response = + handler.handle_message_get_request(None, &client_dest, &request_data, &mut node); + let value: rmpv::Value = rmpv::decode::read_value(&mut &response[..]).unwrap(); + assert_eq!(value.as_u64(), Some(PeerError::NoIdentity as u64)); + } + + #[test] + fn test_message_get_request_list_phase() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let mut node = crate::propagation_node::PropagationNode::new( + crate::propagation_node::PropagationNodeConfig::default(), + [0xBB; 16], + ); + let identity = [0xDD; 16]; + let client_dest = [0xCC; 16]; + + // List phase request: [nil, nil]. + let request_data = { + use rmpv::Value; + let arr = Value::Array(vec![Value::Nil, Value::Nil]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &arr).unwrap(); + buf + }; + + let response = handler.handle_message_get_request( + Some(&identity), + &client_dest, + &request_data, + &mut node, + ); + let value: rmpv::Value = rmpv::decode::read_value(&mut &response[..]).unwrap(); + let arr = value.as_array().unwrap(); + assert!(arr.is_empty()); + } + + #[test] + fn test_peer_sync_request_no_identity() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let peers = HashMap::new(); + + let result = handler.handle_peer_sync_request(None, &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NoIdentity)); + } + + #[test] + fn test_peer_sync_request_no_access() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let peers = HashMap::new(); + let unauthorized = [0xCC; 16]; + + let result = handler.handle_peer_sync_request(Some(&unauthorized), &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NoAccess)); + } + + #[test] + fn test_peer_sync_request_invalid_data() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let peers = HashMap::new(); + + let result = handler.handle_peer_sync_request(Some(&identity), &[0xBB; 8], &peers); + assert!(matches!(result, ControlResult::InvalidData)); + } + + #[test] + fn test_peer_sync_request_not_found() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let peers = HashMap::new(); + + let result = handler.handle_peer_sync_request(Some(&identity), &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NotFound)); + } + + #[test] + fn test_peer_sync_request_success() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let peer_hash = [0xBB; 16]; + let mut peers = HashMap::new(); + peers.insert(peer_hash, crate::peer::LxmPeer::new(peer_hash)); + + let result = handler.handle_peer_sync_request(Some(&identity), &peer_hash, &peers); + match result { + ControlResult::Success(data) => { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).unwrap(); + assert_eq!(value.as_bool(), Some(true)); + } + _ => panic!("expected Success"), + } + } + + #[test] + fn test_peer_unpeer_request_no_identity() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let peers = HashMap::new(); + + let result = handler.handle_peer_unpeer_request(None, &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NoIdentity)); + } + + #[test] + fn test_peer_unpeer_request_no_access() { + let handler = PropagationRequestHandler::new([0xAA; 16]); + let peers = HashMap::new(); + let unauthorized = [0xCC; 16]; + + let result = handler.handle_peer_unpeer_request(Some(&unauthorized), &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NoAccess)); + } + + #[test] + fn test_peer_unpeer_request_not_found() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let peers = HashMap::new(); + + let result = handler.handle_peer_unpeer_request(Some(&identity), &[0xBB; 16], &peers); + assert!(matches!(result, ControlResult::NotFound)); + } + + #[test] + fn test_peer_unpeer_request_success() { + let identity = [0xAA; 16]; + let handler = PropagationRequestHandler::new(identity); + let peer_hash = [0xBB; 16]; + let mut peers = HashMap::new(); + peers.insert(peer_hash, crate::peer::LxmPeer::new(peer_hash)); + + let result = handler.handle_peer_unpeer_request(Some(&identity), &peer_hash, &peers); + match result { + ControlResult::Success(data) => { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).unwrap(); + assert_eq!(value.as_bool(), Some(true)); + } + _ => panic!("expected Success"), + } + } + + #[test] + fn test_throttle_peer() { + let mut handler = PropagationRequestHandler::new([0xAA; 16]); + let peer = [0xBB; 16]; + + assert!(!handler.is_peer_throttled(&peer)); + handler.throttle_peer(peer, 60.0); + assert!(handler.is_peer_throttled(&peer)); + } + + #[test] + fn test_cleanup_throttled_peers() { + let mut handler = PropagationRequestHandler::new([0xAA; 16]); + let peer = [0xBB; 16]; + + handler.throttled_peers.insert(peer, 0.0); + assert!(!handler.is_peer_throttled(&peer)); + handler.cleanup_throttled_peers(); + assert!(handler.throttled_peers.is_empty()); + } + + #[test] + fn test_allow_disallow_control() { + let mut handler = PropagationRequestHandler::new([0xAA; 16]); + let new_identity = [0xBB; 16]; + + assert_eq!(handler.control_allowed.len(), 1); + handler.allow_control(new_identity); + assert_eq!(handler.control_allowed.len(), 2); + + handler.allow_control(new_identity); + assert_eq!(handler.control_allowed.len(), 2); + + handler.disallow_control(&new_identity); + assert_eq!(handler.control_allowed.len(), 1); + assert_eq!(handler.control_allowed[0], [0xAA; 16]); + } +} diff --git a/crates/lxmf-core/src/lib.rs b/crates/lxmf-core/src/lib.rs new file mode 100644 index 0000000..5fe26be --- /dev/null +++ b/crates/lxmf-core/src/lib.rs @@ -0,0 +1,58 @@ +//! Core implementation of the LXMF (Lightweight Extensible Message +//! Format) protocol: messages, routing, propagation, peering, and the +//! PoW stamp primitives. +//! +//! This crate implements the LXMF wire model and is the core LXMF building +//! block for `lxmf-tools` (the `lxmd-rs` daemon) and embedding applications. +//! Its Reticulum dependency is `rns-transport` from rsReticulum; LXMF sits one +//! layer above the Reticulum stack. +//! +//! # Module map +//! +//! | Module | What it does | +//! | --------------------- | -------------------------------------------------- | +//! | [`message`] | Message object: fields, packing, stamp, encryption | +//! | [`router`] | Actor-driven routing and delivery state machine | +//! | [`peer`] | Propagation-peer state and sync bookkeeping | +//! | [`propagation`] | On-disk store-and-forward message pool | +//! | [`propagation_node`] | Propagation-node role logic | +//! | [`propagation_client`]| Client side of propagation sync | +//! | [`propagation_sync`] | Wire-level peer sync exchange | +//! | [`stamper`] | Iterative and HKDF-expanded stamp workblocks | +//! | [`discovery_stamper`] | [`DiscoveryStamper`] impl for on-network discovery | +//! | [`sync`] | Shared peer-to-peer sync primitives | +//! | [`link_delivery`] | Reticulum-link-based delivery path | +//! | [`handlers`] | Callback trait surface for delivery events | +//! | [`ticket`] | Small typed identifier for propagation workflows | +//! | [`persist`] | MessagePack-based on-disk state | +//! | [`constants`] | Wire constants: STATE, METHOD, field IDs, etc. | +//! +//! See also `crates/lxmf-tools/` for the `lxmd-rs` binary, and `rsReticulum` +//! (sibling repo) for the Reticulum protocol stack itself. +//! +//! [`DiscoveryStamper`]: rns_transport::discovery::DiscoveryStamper + +pub mod constants; +pub mod discovery_stamper; +pub mod handlers; +pub mod link_delivery; +pub mod message; +pub mod peer; +pub mod persist; +pub mod propagation; +pub mod propagation_client; +pub mod propagation_node; +pub mod propagation_sync; +pub mod router; +pub mod stamper; +pub mod sync; +pub mod ticket; + +/// Encode an `rmpv::Value` into a byte buffer. +/// +/// `Write` into a `Vec` is infallible, so the inner `expect` is unreachable. +pub(crate) fn encode_value(value: &rmpv::Value) -> Vec { + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, value).expect("internal: Vec write is infallible"); + buf +} diff --git a/crates/lxmf-core/src/link_delivery.rs b/crates/lxmf-core/src/link_delivery.rs new file mode 100644 index 0000000..7c8b746 --- /dev/null +++ b/crates/lxmf-core/src/link_delivery.rs @@ -0,0 +1,1448 @@ +//! Link-based LXMF message delivery (Python's Direct delivery mode). +//! +//! Establishes a link to the recipient, identifies the sender, and transfers the message either +//! as a single encrypted link packet or as a Resource over the link. Enables larger-than-MDU +//! messages via resource segmentation, delivery confirmation via link-level proofs, and sender +//! identity verification via link identification. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use rns_crypto::ed25519::{Ed25519PrivateKey, Ed25519PublicKey}; +use rns_link::link::{CloseReason, Link}; +use rns_protocol::resource::{ + MAX_EFFICIENT_SIZE, MultiSegmentOutbound, OutboundResource, OutboundTransfer, ResourceError, + TransferAction, +}; +use rns_transport::link_messages::DestinationEvent; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use tokio::sync::mpsc; + +use crate::constants::{DeliveryRepresentation, LXMF_OVERHEAD}; +use crate::message::LxMessage; +use crate::propagation::hex_encode; + +/// State of a link-based delivery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryState { + Establishing, + Identifying, + Transferring, + AwaitingProof, + Complete, + Failed, +} + +/// An in-progress link-based delivery. +pub struct PendingDelivery { + pub message: LxMessage, + pub dest_hash: [u8; 16], + pub packed_override: Option>, + pub auto_compress: bool, + pub link: Link, + pub state: DeliveryState, + pub started_at: Instant, + /// Resource transfer, populated after the link establishes. + pub transfer: Option, + /// Remaining Reticulum resource segments for payloads larger than one + /// efficient resource. Segment 1 is stored in `transfer`. + pub remaining_segments: Vec, + /// Full packet hash of a single link-packet LXMF delivery awaiting LINKPROOF. + pub packet_proof_hash: Option<[u8; 32]>, + pub timeout: Duration, + pub msg_hash: Option<[u8; 32]>, + pub failure_reason: Option, +} + +/// Driver for outbound link-based LXMF deliveries. +/// +/// Callers invoke [`Self::start_delivery`] to begin, [`Self::drain_events`] to route inbound +/// packets, and [`Self::tick`] periodically to advance transfers and enforce timeouts. +pub struct LinkDeliveryManager { + transport_tx: mpsc::Sender, + pending: HashMap<[u8; 16], PendingDelivery>, + identity_pub: Option<[u8; 64]>, + identity_key: Option, + event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, +} + +impl LinkDeliveryManager { + pub fn new( + transport_tx: mpsc::Sender, + identity_pub: Option<[u8; 64]>, + identity_key: Option, + ) -> Self { + let (event_tx, event_rx) = mpsc::channel(256); + Self { + transport_tx, + pending: HashMap::new(), + identity_pub, + identity_key, + event_tx, + event_rx, + } + } + + /// Start a direct delivery and return the tracking `link_id`. + pub fn start_delivery( + &mut self, + message: LxMessage, + dest_hash: [u8; 16], + hops: u8, + ) -> [u8; 16] { + self.start_delivery_inner(message, dest_hash, hops, None, true) + } + + /// Start a link delivery with an already-packed payload. + /// + /// This is used for LXMF propagation deposits, whose link payload is the + /// propagation wrapper rather than the regular signed LXMF representation. + pub fn start_packed_delivery( + &mut self, + message: LxMessage, + dest_hash: [u8; 16], + hops: u8, + packed_payload: Vec, + auto_compress: bool, + ) -> [u8; 16] { + self.start_delivery_inner( + message, + dest_hash, + hops, + Some(packed_payload), + auto_compress, + ) + } + + fn start_delivery_inner( + &mut self, + message: LxMessage, + dest_hash: [u8; 16], + hops: u8, + packed_override: Option>, + auto_compress: bool, + ) -> [u8; 16] { + let msg_hash = message.hash; + let (link, request_data) = Link::new_initiator(dest_hash, hops); + let link_id = link.link_id; + + // Register the ephemeral link_id so proofs and data route back to us. + if let Err(e) = self + .transport_tx + .try_send(TransportMessage::RegisterDestination { + hash: link_id, + app_name: "lxmf.delivery.link".to_string(), + delivery_tx: Some(self.event_tx.clone()), + }) + { + tracing::warn!(err = %e, + "failed to register link delivery destination; packets will not route back"); + } + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::LinkRequest, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: dest_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&request_data); + + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: dest_hash, + })); + + // ESTABLISHMENT_TIMEOUT_PER_HOP(6s) * max(1, hops) + KEEPALIVE(360s). + let timeout_secs = 6.0 * (hops.max(1) as f64) + 360.0; + self.pending.insert( + link_id, + PendingDelivery { + message, + dest_hash, + packed_override, + auto_compress, + link, + state: DeliveryState::Establishing, + started_at: Instant::now(), + transfer: None, + remaining_segments: Vec::new(), + packet_proof_hash: None, + timeout: Duration::from_secs_f64(timeout_secs), + msg_hash, + failure_reason: None, + }, + ); + + link_id + } + + /// Drain inbound transport events and dispatch by packet context. + /// + /// Call before [`Self::tick`] each cycle. Routes `LRPROOF`, `ResourceHmu`, `ResourceReq`, + /// and `ResourcePrf` contexts to their handlers. + pub fn drain_events(&mut self, known_identities: &HashMap) { + let mut events = Vec::new(); + while let Ok(event) = self.event_rx.try_recv() { + events.push(event); + } + + for event in events { + match event { + DestinationEvent::LinkClosed { link_id } => { + self.handle_link_closed(&link_id, None); + } + DestinationEvent::InboundPacket { raw, .. } => { + let (header, data_offset) = match rns_wire::header::PacketHeader::unpack(&raw) { + Ok(h) => h, + Err(_) => continue, + }; + let data = if raw.len() > data_offset { + &raw[data_offset..] + } else { + &[] + }; + let link_id = header.destination_hash; + + match header.context { + rns_wire::context::PacketContext::Lrproof + if header.flags.packet_type == rns_wire::flags::PacketType::Proof => + { + let dest_hex = + self.pending.get(&link_id).map(|d| hex_encode(&d.dest_hash)); + + if let Some(dest_hex) = dest_hex + && let Some(pub_key) = known_identities.get(&dest_hex) + { + let ed25519_bytes: [u8; 32] = pub_key[32..64] + .try_into() + .expect("known_identities values are [u8; 64]; slice [32..64] is always 32 bytes"); + if let Ok(verify_key) = Ed25519PublicKey::from_bytes(&ed25519_bytes) + { + self.handle_link_proof( + &link_id, + data, + &verify_key, + &ed25519_bytes, + ); + } + } + } + rns_wire::context::PacketContext::None + if header.flags.packet_type == rns_wire::flags::PacketType::Proof => + { + // Python `Link.prove_packet()` sends packet proofs on a LINK + // destination with PROOF type and the default/None context. LRPROOF + // handling also accepts None on some older paths, so disambiguate by + // the delivery state. + if self + .pending + .get(&link_id) + .is_some_and(|d| d.state == DeliveryState::AwaitingProof) + { + self.handle_link_packet_proof(&link_id, data); + } else { + let dest_hex = + self.pending.get(&link_id).map(|d| hex_encode(&d.dest_hash)); + + if let Some(dest_hex) = dest_hex + && let Some(pub_key) = known_identities.get(&dest_hex) + { + let ed25519_bytes: [u8; 32] = pub_key[32..64] + .try_into() + .expect("known_identities values are [u8; 64]; slice [32..64] is always 32 bytes"); + if let Ok(verify_key) = + Ed25519PublicKey::from_bytes(&ed25519_bytes) + { + self.handle_link_proof( + &link_id, + data, + &verify_key, + &ed25519_bytes, + ); + } + } + } + } + rns_wire::context::PacketContext::LinkProof + if header.flags.packet_type == rns_wire::flags::PacketType::Proof => + { + self.handle_link_packet_proof(&link_id, data); + } + rns_wire::context::PacketContext::ResourceHmu => { + let plaintext = self + .pending + .get(&link_id) + .and_then(|d| d.link.decrypt(data).ok()); + if let Some(pt) = plaintext { + self.handle_hmu(&link_id, &pt); + } + } + rns_wire::context::PacketContext::ResourceReq => { + // Python `Resource.request_next` may arrive before any HMU and be the + // only signal to advance the transfer, so drive it here directly. + let plaintext = self + .pending + .get(&link_id) + .and_then(|d| d.link.decrypt(data).ok()); + if let Some(pt) = plaintext { + self.handle_request(&link_id, &pt); + } + } + rns_wire::context::PacketContext::ResourcePrf => { + // PROOF+RESOURCE_PRF is plaintext on a Proof packet (Packet.py:195-197). + // Body = resource_hash(32) || proof(32); pass through without decrypt. + self.handle_resource_proof(&link_id, data); + } + rns_wire::context::PacketContext::ResourceRcl => { + // Receiver-cancel/reject packets are link-encrypted and carry + // the rejected resource_hash. + let plaintext = self + .pending + .get(&link_id) + .and_then(|d| d.link.decrypt(data).ok()); + if let Some(pt) = plaintext { + self.handle_resource_reject(&link_id, &pt); + } + } + rns_wire::context::PacketContext::LinkClose => { + self.handle_link_closed(&link_id, Some(data)); + } + _ => {} + } + } + _ => {} + } + } + } + + /// Validate an inbound `LRPROOF`, complete the handshake, and transition to + /// [`DeliveryState::Identifying`]. + pub fn handle_link_proof( + &mut self, + link_id: &[u8; 16], + proof_data: &[u8], + identity_verify_key: &Ed25519PublicKey, + identity_ed25519_pub: &[u8; 32], + ) -> bool { + let Some(delivery) = self.pending.get_mut(link_id) else { + return false; + }; + + if delivery.state != DeliveryState::Establishing { + return false; + } + + match delivery + .link + .validate_proof(proof_data, identity_verify_key, identity_ed25519_pub) + { + Ok(rtt_data) => { + // Message 3 of the handshake: RTT. + let rtt_flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }; + let rtt_header = rns_wire::header::PacketHeader { + flags: rtt_flags, + hops: 0, + transport_id: None, + destination_hash: *link_id, + context: rns_wire::context::PacketContext::Lrrtt, + }; + let mut rtt_raw = rtt_header.pack(); + rtt_raw.extend_from_slice(&rtt_data); + + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(rtt_raw), + destination_hash: *link_id, + })); + + delivery.state = DeliveryState::Identifying; + true + } + Err(e) => { + let _ = e; + delivery.state = DeliveryState::Failed; + delivery.failure_reason = Some("link proof validation failed".to_string()); + false + } + } + } + + /// Drive pending deliveries forward; call periodically after [`Self::drain_events`]. + pub fn tick(&mut self) -> Vec { + let mut results = Vec::new(); + let mut to_remove = Vec::new(); + + for (link_id, delivery) in &mut self.pending { + if delivery.started_at.elapsed() > delivery.timeout { + delivery.state = DeliveryState::Failed; + delivery.failure_reason = Some("delivery timeout".to_string()); + results.push(DeliveryResult::Failed { + link_id: *link_id, + msg_hash: delivery.msg_hash, + reason: "delivery timeout".to_string(), + }); + to_remove.push(*link_id); + continue; + } + + match delivery.state { + DeliveryState::Identifying if delivery.link.is_active() => { + if let (Some(pub_key), Some(sign_key)) = + (&self.identity_pub, &self.identity_key) + && let Ok(identify_data) = delivery.link.identify(pub_key, sign_key) + { + let id_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: *link_id, + context: rns_wire::context::PacketContext::LinkIdentify, + }; + let mut id_raw = id_header.pack(); + id_raw.extend_from_slice(&identify_data); + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + OutboundRequest { + raw: Bytes::from(id_raw), + destination_hash: *link_id, + }, + )); + } + // Advance regardless of identification success. + delivery.state = DeliveryState::Transferring; + + let packed = if let Some(ref packed) = delivery.packed_override { + Ok(packed.clone()) + } else { + delivery.message.pack() + }; + if let Ok(packed) = packed { + let packet_limit = if delivery.packed_override.is_some() { + delivery.link.mdu.saturating_sub(LXMF_OVERHEAD) + } else { + delivery.link.mdu + }; + if packed.len() <= packet_limit { + // Python LXMessage sends Direct messages that fit in Link.MDU + // as a single encrypted link packet, then waits for LINKPROOF. + delivery.message.representation = DeliveryRepresentation::Packet; + match send_link_packet(link_id, delivery, &self.transport_tx, &packed) { + Some(packet_hash) => { + delivery.packet_proof_hash = Some(packet_hash); + delivery.state = DeliveryState::AwaitingProof; + } + None => { + delivery.state = DeliveryState::Failed; + delivery.failure_reason = + Some("link packet encryption failed".to_string()); + } + } + } else { + delivery.message.representation = DeliveryRepresentation::Resource; + // Python's Resource encrypts the blob with link session keys + // BEFORE chunking (Resource.py:424), and resource parts are sent + // on the wire WITHOUT additional packet-layer encryption + // (Packet.py:201-204). + let rtt = delivery.link.rtt.unwrap_or(Duration::from_millis(500)); + let auto_compress = if delivery.packed_override.is_some() { + delivery.auto_compress + } else { + delivery.message.auto_compress + }; + let transfer_result = + build_resource_transfer(&delivery.link, packed, auto_compress, rtt); + match transfer_result { + Ok((transfer, remaining_segments)) => { + delivery.transfer = Some(transfer); + delivery.remaining_segments = remaining_segments; + } + Err(e) => { + let _ = e; + delivery.state = DeliveryState::Failed; + delivery.failure_reason = + Some("resource transfer build failed".to_string()); + } + } + } + } + } + DeliveryState::Identifying => {} + DeliveryState::Transferring => { + // Process up to a full window of actions per tick so the 500ms tick rate + // doesn't throttle us below link speed. + let max_actions = 16; + for _ in 0..max_actions { + if delivery.state != DeliveryState::Transferring { + break; + } + let Some(ref mut transfer) = delivery.transfer else { + break; + }; + let action = transfer.tick(); + match dispatch_action(link_id, delivery, &self.transport_tx, action) { + ActionOutcome::Continue => continue, + ActionOutcome::Break => break, + ActionOutcome::Complete => { + results.push(DeliveryResult::Complete { + link_id: *link_id, + msg_hash: delivery.msg_hash, + }); + to_remove.push(*link_id); + } + ActionOutcome::Fail(reason) => { + results.push(DeliveryResult::Failed { + link_id: *link_id, + msg_hash: delivery.msg_hash, + reason, + }); + to_remove.push(*link_id); + } + } + } + } + DeliveryState::Complete => { + results.push(DeliveryResult::Complete { + link_id: *link_id, + msg_hash: delivery.msg_hash, + }); + to_remove.push(*link_id); + } + DeliveryState::Failed => { + let reason = delivery + .failure_reason + .take() + .unwrap_or_else(|| "delivery failed".to_string()); + results.push(DeliveryResult::Failed { + link_id: *link_id, + msg_hash: delivery.msg_hash, + reason, + }); + to_remove.push(*link_id); + } + _ => {} + } + } + + for link_id in to_remove { + if let Some(mut delivery) = self.pending.remove(&link_id) { + send_link_teardown(&self.transport_tx, &link_id, &mut delivery.link); + let _ = self + .transport_tx + .try_send(TransportMessage::DeregisterDestination { hash: link_id }); + } + } + + results + } + + pub fn handle_hmu(&mut self, link_id: &[u8; 16], hmu_data: &[u8]) { + if let Some(delivery) = self.pending.get_mut(link_id) + && let Some(ref mut transfer) = delivery.transfer + { + transfer.handle_hmu(hmu_data); + } + } + + /// Handle an inbound `RESOURCE_REQ` (receiver's `request_next`). + /// + /// The request returns a list of parts the receiver still needs; dispatch the resulting + /// `SendPart` actions immediately rather than waiting for the next [`Self::tick`], since + /// the receiver may time out and retry first. + pub fn handle_request(&mut self, link_id: &[u8; 16], request_data: &[u8]) { + let Some(delivery) = self.pending.get_mut(link_id) else { + return; + }; + let Some(ref mut transfer) = delivery.transfer else { + return; + }; + let actions = transfer.handle_request(request_data); + for action in actions { + match dispatch_action(link_id, delivery, &self.transport_tx, action) { + ActionOutcome::Continue | ActionOutcome::Break => {} + ActionOutcome::Complete => { + break; + } + ActionOutcome::Fail(reason) => { + delivery.failure_reason = Some(reason); + // Terminal state is surfaced on the next tick() via delivery.state. + break; + } + } + } + } + + /// Apply an inbound resource proof; returns `true` when the proof was accepted. + pub fn handle_resource_proof(&mut self, link_id: &[u8; 16], proof_data: &[u8]) -> bool { + if let Some(delivery) = self.pending.get_mut(link_id) + && let Some(ref mut transfer) = delivery.transfer + && transfer.handle_proof(proof_data) + { + if delivery.remaining_segments.is_empty() { + delivery.state = DeliveryState::Complete; + } else { + let rtt = delivery.link.rtt.unwrap_or(Duration::from_millis(500)); + let next_segment = delivery.remaining_segments.remove(0); + delivery.transfer = Some(OutboundTransfer::from_prebuilt(next_segment, rtt)); + delivery.state = DeliveryState::Transferring; + } + return true; + } + false + } + + /// Apply an inbound receiver-cancel/reject for the current outbound resource. + pub fn handle_resource_reject(&mut self, link_id: &[u8; 16], reject_data: &[u8]) -> bool { + if reject_data.len() < 32 { + return false; + } + + let mut rejected_hash = [0u8; 32]; + rejected_hash.copy_from_slice(&reject_data[..32]); + + if let Some(delivery) = self.pending.get_mut(link_id) + && let Some(ref mut transfer) = delivery.transfer + && transfer.resource.resource_hash == rejected_hash + { + transfer.handle_cancel(); + delivery.remaining_segments.clear(); + delivery.state = DeliveryState::Failed; + delivery.failure_reason = Some("resource rejected".to_string()); + return true; + } + + false + } + + fn handle_link_closed( + &mut self, + link_id: &[u8; 16], + encrypted_teardown: Option<&[u8]>, + ) -> bool { + let Some(delivery) = self.pending.get_mut(link_id) else { + return false; + }; + + let verified = match encrypted_teardown { + Some(data) => delivery.link.receive_teardown(data), + None => { + delivery.link.mark_closed(CloseReason::DestinationClosed); + true + } + }; + + if verified { + if delivery.state == DeliveryState::Complete { + return true; + } + delivery.transfer = None; + delivery.remaining_segments.clear(); + delivery.packet_proof_hash = None; + delivery.state = DeliveryState::Failed; + delivery.failure_reason = Some("link closed".to_string()); + } + + verified + } + + /// Apply an inbound link-packet proof; returns `true` when the packet delivery is complete. + pub fn handle_link_packet_proof(&mut self, link_id: &[u8; 16], proof_data: &[u8]) -> bool { + if let Some(delivery) = self.pending.get_mut(link_id) + && delivery.state == DeliveryState::AwaitingProof + && let Some(packet_hash) = delivery.packet_proof_hash + && delivery + .link + .validate_packet_proof(&packet_hash, proof_data) + { + delivery.state = DeliveryState::Complete; + return true; + } + false + } + + pub fn pending_count(&self) -> usize { + self.pending.len() + } +} + +fn build_resource_transfer( + link: &Link, + packed: Vec, + auto_compress: bool, + rtt: Duration, +) -> Result<(OutboundTransfer, Vec), ResourceError> { + if packed.len() <= MAX_EFFICIENT_SIZE { + let transfer = match link.session_keys() { + Some(keys) => { + OutboundTransfer::new_encrypted(packed, auto_compress, rtt, keys.clone())? + } + None => OutboundTransfer::new(packed, auto_compress, rtt)?, + }; + return Ok((transfer, Vec::new())); + } + + let multi = match link.session_keys() { + Some(keys) => { + let keys = keys.clone(); + let encrypt_fn = |plaintext: &[u8]| -> Vec { + rns_link::encryption::link_encrypt(&keys, plaintext) + .unwrap_or_else(|_| plaintext.to_vec()) + }; + MultiSegmentOutbound::with_encrypt(packed, auto_compress, Some(&encrypt_fn))? + } + None => MultiSegmentOutbound::new(packed, auto_compress)?, + }; + + let mut segments = multi.segments.into_iter(); + let first = segments.next().ok_or(ResourceError::Incomplete)?; + Ok(( + OutboundTransfer::from_prebuilt(first, rtt), + segments.collect(), + )) +} + +/// Result of a delivery tick. +#[derive(Debug)] +pub enum DeliveryResult { + Complete { + link_id: [u8; 16], + msg_hash: Option<[u8; 32]>, + }, + Failed { + link_id: [u8; 16], + msg_hash: Option<[u8; 32]>, + reason: String, + }, +} + +/// Outcome of dispatching one [`TransferAction`] onto the wire. +enum ActionOutcome { + /// Action dispatched, continue draining. + Continue, + /// No-op; stop draining for this cycle. + Break, + /// Transfer completed; `delivery.state` is already [`DeliveryState::Complete`]. + Complete, + /// Transfer failed; `delivery.state` is already [`DeliveryState::Failed`]. + Fail(String), +} + +/// Send a single LXMF packet over an active link and return the full packet hash that the peer +/// must prove with `LINKPROOF`. +fn send_link_packet( + link_id: &[u8; 16], + delivery: &mut PendingDelivery, + transport_tx: &mpsc::Sender, + packed: &[u8], +) -> Option<[u8; 32]> { + let encrypted = delivery.link.encrypt(packed).ok()?; + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: *link_id, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&encrypted); + let packet_hash = rns_wire::hash::packet_hash(&raw, rns_wire::flags::HeaderType::Header1); + let _ = transport_tx.try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: *link_id, + })); + delivery.link.record_tx(encrypted.len()); + Some(packet_hash) +} + +fn send_link_teardown( + transport_tx: &mpsc::Sender, + link_id: &[u8; 16], + link: &mut Link, +) { + let Some(teardown_data) = link.teardown(CloseReason::InitiatorClosed) else { + return; + }; + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: *link_id, + context: rns_wire::context::PacketContext::LinkClose, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&teardown_data); + let _ = transport_tx.try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: *link_id, + })); +} + +/// Serialize a [`TransferAction`] onto the link and enqueue it for transport. +/// +/// Kept as a free function so it can be called from [`LinkDeliveryManager::tick`] and +/// [`LinkDeliveryManager::handle_request`] without double-mutable-borrow conflicts on the +/// manager. +fn dispatch_action( + link_id: &[u8; 16], + delivery: &mut PendingDelivery, + transport_tx: &mpsc::Sender, + action: TransferAction, +) -> ActionOutcome { + let base_flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }; + let make_header = |context, packet_type| rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + packet_type, + ..base_flags + }, + hops: 0, + transport_id: None, + destination_hash: *link_id, + context, + }; + let send = |header: rns_wire::header::PacketHeader, body: &[u8]| { + let mut raw = header.pack(); + raw.extend_from_slice(body); + let _ = transport_tx.try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: *link_id, + })); + }; + + match action { + TransferAction::SendAdvertisement(adv_data) => { + if let Ok(encrypted) = delivery.link.encrypt(&adv_data) { + send( + make_header( + rns_wire::context::PacketContext::ResourceAdv, + rns_wire::flags::PacketType::Data, + ), + &encrypted, + ); + delivery.link.record_tx(encrypted.len()); + } + ActionOutcome::Continue + } + TransferAction::SendPart(_, part_data) => { + // Parts are already ciphertext (pre-chunk blob encryption). `context=Resource` + // packets are not packet-layer encrypted (Packet.py:201-204). + send( + make_header( + rns_wire::context::PacketContext::Resource, + rns_wire::flags::PacketType::Data, + ), + &part_data, + ); + delivery.link.record_tx(part_data.len()); + ActionOutcome::Continue + } + TransferAction::SendHmu(hmu_data) => { + if let Ok(encrypted) = delivery.link.encrypt(&hmu_data) { + send( + make_header( + rns_wire::context::PacketContext::ResourceHmu, + rns_wire::flags::PacketType::Data, + ), + &encrypted, + ); + delivery.link.record_tx(encrypted.len()); + } + ActionOutcome::Continue + } + TransferAction::SendRequest(req_data) => { + if let Ok(encrypted) = delivery.link.encrypt(&req_data) { + send( + make_header( + rns_wire::context::PacketContext::ResourceReq, + rns_wire::flags::PacketType::Data, + ), + &encrypted, + ); + delivery.link.record_tx(encrypted.len()); + } + ActionOutcome::Continue + } + TransferAction::SendProof(proof_data) => { + // PROOF+RESOURCE_PRF is plaintext on a Proof packet (Packet.py:195-197). Body = + // resource_hash(32) || proof(32). + send( + make_header( + rns_wire::context::PacketContext::ResourcePrf, + rns_wire::flags::PacketType::Proof, + ), + &proof_data, + ); + delivery.link.record_tx(proof_data.len()); + ActionOutcome::Continue + } + TransferAction::Complete => { + delivery.state = DeliveryState::Complete; + ActionOutcome::Complete + } + TransferAction::Failed(reason) => { + delivery.state = DeliveryState::Failed; + ActionOutcome::Fail(reason) + } + TransferAction::SendCancel(cancel_type, resource_hash) => { + if let Ok(encrypted) = delivery.link.encrypt(&resource_hash) { + let context = match cancel_type { + rns_protocol::resource::CancelType::Icl => { + rns_wire::context::PacketContext::ResourceIcl + } + rns_protocol::resource::CancelType::Rcl => { + rns_wire::context::PacketContext::ResourceRcl + } + }; + send( + make_header(context, rns_wire::flags::PacketType::Data), + &encrypted, + ); + delivery.link.record_tx(encrypted.len()); + } + delivery.state = DeliveryState::Failed; + ActionOutcome::Fail("resource transfer cancelled".to_string()) + } + TransferAction::None => ActionOutcome::Break, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn next_outbound(rx: &mut mpsc::Receiver) -> Vec { + while let Ok(message) = rx.try_recv() { + if let TransportMessage::Outbound(request) = message { + return request.raw.to_vec(); + } + } + panic!("expected outbound transport message"); + } + + fn establish_active_delivery( + mgr: &mut LinkDeliveryManager, + rx: &mut mpsc::Receiver, + msg: LxMessage, + responder_key: &Ed25519PrivateKey, + dest_hash: [u8; 16], + ) -> ([u8; 16], Link) { + let link_id = mgr.start_delivery(msg, dest_hash, 1); + + let request_raw = next_outbound(rx); + let (request_header, request_offset) = + rns_wire::header::PacketHeader::unpack(&request_raw).unwrap(); + assert_eq!( + request_header.flags.packet_type, + rns_wire::flags::PacketType::LinkRequest + ); + + let (mut responder_link, proof_data) = + Link::new_responder(&request_raw[request_offset..], responder_key, dest_hash, 1) + .unwrap(); + let responder_pub = responder_key.public_key(); + assert!(mgr.handle_link_proof( + &link_id, + &proof_data, + &responder_pub, + &responder_pub.to_bytes() + )); + + let rtt_raw = next_outbound(rx); + let (rtt_header, rtt_offset) = rns_wire::header::PacketHeader::unpack(&rtt_raw).unwrap(); + assert_eq!(rtt_header.context, rns_wire::context::PacketContext::Lrrtt); + responder_link + .receive_rtt_packet(&rtt_raw[rtt_offset..]) + .unwrap(); + + (link_id, responder_link) + } + + fn link_data_packet( + link_id: [u8; 16], + context: rns_wire::context::PacketContext, + payload: &[u8], + ) -> Bytes { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(payload); + Bytes::from(raw) + } + + #[test] + fn test_link_delivery_manager_creation() { + let (tx, _rx) = mpsc::channel(16); + let mgr = LinkDeliveryManager::new(tx, None, None); + assert_eq!(mgr.pending_count(), 0); + } + + #[test] + fn test_start_delivery_registers_with_transport() { + let (tx, mut rx) = mpsc::channel(64); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test Subject", + "test message for link delivery", + crate::constants::DeliveryMethod::Direct, + ); + let dest_hash = [0xCC; 16]; + + let link_id = mgr.start_delivery(msg, dest_hash, 1); + assert_eq!(mgr.pending_count(), 1); + + let register = rx.try_recv(); + assert!(register.is_ok(), "RegisterDestination should be queued"); + assert!(matches!( + register.unwrap(), + TransportMessage::RegisterDestination { .. } + )); + + let outbound = rx.try_recv(); + assert!(outbound.is_ok(), "link request should be queued"); + + let delivery = mgr.pending.get(&link_id).unwrap(); + assert_eq!(delivery.state, DeliveryState::Establishing); + assert_eq!(delivery.dest_hash, dest_hash); + } + + #[test] + fn test_delivery_timeout_deregisters() { + let (tx, mut rx) = mpsc::channel(64); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let msg = LxMessage::new( + [0; 16], + [0; 16], + "Timeout", + "timeout test", + crate::constants::DeliveryMethod::Direct, + ); + let link_id = mgr.start_delivery(msg, [0xDD; 16], 1); + + while rx.try_recv().is_ok() {} + + if let Some(delivery) = mgr.pending.get_mut(&link_id) { + delivery.timeout = Duration::ZERO; + } + + let results = mgr.tick(); + assert!( + results + .iter() + .any(|r| matches!(r, DeliveryResult::Failed { .. })) + ); + assert_eq!(mgr.pending_count(), 0); + + let deregister = rx.try_recv(); + assert!(deregister.is_ok(), "DeregisterDestination should be queued"); + assert!(matches!( + deregister.unwrap(), + TransportMessage::DeregisterDestination { .. } + )); + } + + #[test] + fn test_over_mtu_message_tracks_hash() { + let (tx, _rx) = mpsc::channel(64); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Large Message", + &"x".repeat(1000), + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + let expected_hash = msg.hash; + + let link_id = mgr.start_delivery(msg, [0xCC; 16], 1); + let delivery = mgr.pending.get(&link_id).unwrap(); + assert_eq!(delivery.msg_hash, expected_hash); + } + + #[test] + fn test_over_efficient_limit_direct_uses_split_resources() { + let (tx, mut rx) = mpsc::channel(512); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Split Direct", + &"x".repeat(MAX_EFFICIENT_SIZE + 256), + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + assert!(msg.pack().unwrap().len() > MAX_EFFICIENT_SIZE); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCC; 16]; + let (link_id, _responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + let results = mgr.tick(); + assert!(results.is_empty()); + + let delivery = mgr.pending.get(&link_id).unwrap(); + let transfer = delivery.transfer.as_ref().expect("first segment transfer"); + assert!(transfer.resource.flags.split); + assert_eq!(transfer.resource.segment_index, 1); + assert!(transfer.resource.total_segments >= 2); + assert_eq!( + delivery.remaining_segments.len(), + transfer.resource.total_segments - 1 + ); + } + + #[test] + fn test_split_resource_proof_advances_to_next_segment() { + let (tx, mut rx) = mpsc::channel(512); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Split Direct", + &"y".repeat(MAX_EFFICIENT_SIZE + 128), + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCC; 16]; + let (link_id, _responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + let results = mgr.tick(); + assert!(results.is_empty()); + + let first_proof = { + let delivery = mgr.pending.get(&link_id).unwrap(); + let transfer = delivery.transfer.as_ref().unwrap(); + assert!(transfer.resource.total_segments >= 2); + let mut proof = Vec::new(); + proof.extend_from_slice(&transfer.resource.resource_hash); + proof.extend_from_slice(&transfer.resource.expected_proof); + proof + }; + assert!(mgr.handle_resource_proof(&link_id, &first_proof)); + + let delivery = mgr.pending.get(&link_id).unwrap(); + assert_eq!(delivery.state, DeliveryState::Transferring); + assert_eq!( + delivery.transfer.as_ref().unwrap().resource.segment_index, + 2 + ); + + let mut terminal_proofs = Vec::new(); + loop { + let delivery = mgr.pending.get(&link_id).unwrap(); + let transfer = delivery.transfer.as_ref().unwrap(); + let mut proof = Vec::new(); + proof.extend_from_slice(&transfer.resource.resource_hash); + proof.extend_from_slice(&transfer.resource.expected_proof); + terminal_proofs.push(proof); + if delivery.remaining_segments.is_empty() { + break; + } + let proof = terminal_proofs.pop().unwrap(); + assert!(mgr.handle_resource_proof(&link_id, &proof)); + } + + let final_proof = terminal_proofs.pop().unwrap(); + assert!(mgr.handle_resource_proof(&link_id, &final_proof)); + let results = mgr.tick(); + assert!( + results + .iter() + .any(|r| matches!(r, DeliveryResult::Complete { .. })) + ); + assert_eq!(mgr.pending_count(), 0); + } + + #[test] + fn test_resource_reject_fails_delivery() { + let (tx, mut rx) = mpsc::channel(512); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Resource Reject", + &"z".repeat(1000), + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCC; 16]; + let (link_id, _responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + let results = mgr.tick(); + assert!(results.is_empty()); + + let resource_hash = mgr + .pending + .get(&link_id) + .unwrap() + .transfer + .as_ref() + .unwrap() + .resource + .resource_hash; + + assert!(mgr.handle_resource_reject(&link_id, &resource_hash)); + let results = mgr.tick(); + assert!( + results + .iter() + .any(|r| matches!(r, DeliveryResult::Failed { .. })) + ); + assert_eq!(mgr.pending_count(), 0); + } + + #[test] + fn test_authenticated_remote_link_close_fails_and_deregisters() { + let (tx, mut rx) = mpsc::channel(512); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Remote Close", + "close before delivery proof", + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCD; 16]; + let (link_id, mut responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + while rx.try_recv().is_ok() {} + + let close_body = responder_link + .teardown(CloseReason::InitiatorClosed) + .expect("remote active link emits authenticated teardown"); + mgr.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_body, + ), + interface_id: 0, + }) + .unwrap(); + + mgr.drain_events(&HashMap::new()); + let results = mgr.tick(); + + assert!(results.iter().any(|r| matches!( + r, + DeliveryResult::Failed { reason, .. } if reason == "link closed" + ))); + assert_eq!(mgr.pending_count(), 0); + assert!(matches!( + rx.try_recv().unwrap(), + TransportMessage::DeregisterDestination { hash } if hash == link_id + )); + } + + #[test] + fn test_unauthenticated_link_close_is_ignored() { + let (tx, mut rx) = mpsc::channel(512); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Bad Close", + "ignore invalid close packet", + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCE; 16]; + let (link_id, _responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + mgr.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet(link_id, rns_wire::context::PacketContext::LinkClose, &[0u8]), + interface_id: 0, + }) + .unwrap(); + + mgr.drain_events(&HashMap::new()); + assert_eq!(mgr.pending_count(), 1); + assert_ne!( + mgr.pending.get(&link_id).unwrap().state, + DeliveryState::Failed + ); + } + + #[test] + fn test_small_direct_uses_link_packet_and_accepts_python_style_proof() { + let (tx, mut rx) = mpsc::channel(64); + let mut mgr = LinkDeliveryManager::new(tx, None, None); + + let sign_key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Small Direct", + "fits in one link packet", + crate::constants::DeliveryMethod::Direct, + ); + msg.sign(&sign_key).unwrap(); + let packed = msg.pack().unwrap(); + + let responder_key = Ed25519PrivateKey::generate(); + let dest_hash = [0xCC; 16]; + let (link_id, mut responder_link) = + establish_active_delivery(&mut mgr, &mut rx, msg, &responder_key, dest_hash); + + let results = mgr.tick(); + assert!(results.is_empty()); + + let packet_raw = next_outbound(&mut rx); + let (packet_header, packet_offset) = + rns_wire::header::PacketHeader::unpack(&packet_raw).unwrap(); + assert_eq!( + packet_header.flags.destination_type, + rns_wire::flags::DestinationType::Link + ); + assert_eq!( + packet_header.flags.packet_type, + rns_wire::flags::PacketType::Data + ); + assert_eq!( + packet_header.context, + rns_wire::context::PacketContext::None + ); + + let decrypted = responder_link + .decrypt(&packet_raw[packet_offset..]) + .unwrap(); + assert_eq!(decrypted, packed); + + let packet_hash = rns_wire::hash::packet_hash(&packet_raw, packet_header.flags.header_type); + let delivery = mgr.pending.get(&link_id).unwrap(); + assert_eq!(delivery.state, DeliveryState::AwaitingProof); + assert_eq!(delivery.packet_proof_hash, Some(packet_hash)); + + let proof_data = responder_link + .prove_packet(&packet_hash, &responder_key) + .unwrap(); + let proof_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Proof, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::None, + }; + let mut proof_raw = proof_header.pack(); + proof_raw.extend_from_slice(&proof_data); + mgr.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: proof_raw.into(), + interface_id: 0, + }) + .unwrap(); + let close_body = responder_link + .teardown(CloseReason::InitiatorClosed) + .expect("remote active link emits authenticated teardown after proof"); + mgr.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_body, + ), + interface_id: 0, + }) + .unwrap(); + + mgr.drain_events(&HashMap::new()); + let results = mgr.tick(); + assert!( + results + .iter() + .any(|r| matches!(r, DeliveryResult::Complete { .. })) + ); + assert_eq!(mgr.pending_count(), 0); + } +} diff --git a/crates/lxmf-core/src/message.rs b/crates/lxmf-core/src/message.rs new file mode 100644 index 0000000..589097c --- /dev/null +++ b/crates/lxmf-core/src/message.rs @@ -0,0 +1,2285 @@ +//! LXMF message construction, packing, unpacking, and state machine. +//! +//! Python reference: LXMF/LXMessage.py. +//! +//! # Wire formats +//! +//! Direct / link / opportunistic: +//! `[dest_hash:16][src_hash:16][signature:64][msgpack([ts, title, content, fields, ?stamp])]`. +//! Title and content are msgpack `bin` (not `str`) to match Python/C++. +//! +//! Propagation (LXMessage.py:434-441): +//! ```text +//! encrypted_data = destination.encrypt(packed[16..]) +//! lxmf_data = packed[..16] + encrypted_data +//! transient_id = full_hash(lxmf_data)[..16] +//! lxmf_data += ?propagation_stamp +//! propagation_packed = msgpack([timestamp, [lxmf_data]]) +//! ``` +//! +//! Signed blob: `dest_hash + src_hash + payload + SHA256(dest_hash + src_hash + payload)` +//! (LXMessage.py:380-383). + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rns_crypto::sha::{full_hash, sha256, truncated_hash}; +use serde::{Deserialize, Serialize}; + +use crate::constants::*; + +/// Shared handler invoked on per-message state transitions. +pub type MessageCallback = Arc; + +/// Per-message delivery and failure callbacks. +/// +/// Invoked by [`LxMessage::notify_delivered`] / [`LxMessage::notify_failed`] when the router +/// observes a state transition for a tracked outbound message. +#[derive(Clone, Default)] +pub struct MessageCallbacks { + pub on_delivered: Option, + pub on_failed: Option, +} + +impl std::fmt::Debug for MessageCallbacks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MessageCallbacks") + .field("on_delivered", &self.on_delivered.is_some()) + .field("on_failed", &self.on_failed.is_some()) + .finish() + } +} + +/// An LXMF message. +#[derive(Debug, Clone)] +pub struct LxMessage { + pub destination_hash: [u8; 16], + pub source_hash: [u8; 16], + pub title: String, + pub content: String, + /// Application-level fields keyed by field ID. + pub fields: BTreeMap>, + /// Field IDs whose values are already msgpack-encoded LXMF values. + /// + /// Most custom fields are byte strings and must serialize as msgpack `bin`. + /// Native structured fields such as `FIELD_IMAGE = [format, bytes]` and + /// `FIELD_FILE_ATTACHMENTS = [[name, bytes], ...]` must serialize as their + /// contained msgpack value instead. + pub msgpack_field_ids: BTreeSet, + /// Unix epoch seconds. + pub timestamp: f64, + pub signature: Option<[u8; 64]>, + pub signature_validated: bool, + pub state: MessageState, + pub method: DeliveryMethod, + /// Message stamp, if present. + /// + /// LXMF uses 32-byte PoW stamps and 16-byte ticket stamps, so this must stay + /// byte-sized instead of forcing the PoW length. + pub stamp: Option>, + /// Propagation PoW stamp, if present (32 bytes). + pub propagation_stamp: Option<[u8; 32]>, + pub ratchet_id: Option<[u8; 32]>, + pub delivery_attempts: u32, + /// Unix timestamp of the last delivery attempt; 0.0 means never attempted. + pub last_delivery_attempt: f64, + /// SHA-256 of the packed message. + pub hash: Option<[u8; 32]>, + /// Alias for [`hash`](Self::hash) set after packing. + pub message_id: Option<[u8; 32]>, + /// Truncated hash used by the propagation offer/get protocol. + pub transient_id: Option<[u8; 16]>, + /// Destination-required stamp cost for outbound stamp generation. + pub stamp_cost: Option, + /// Outbound ticket for stamp bypass (16 bytes). + pub outbound_ticket: Option<[u8; 16]>, + /// Computed stamp value: leading zero bits, or `COST_TICKET`. + pub stamp_value: Option, + pub unverified_reason: Option, + pub representation: DeliveryRepresentation, + pub transport_encrypted: bool, + /// Original msgpack payload bytes, stored on inbound messages so signature verification + /// can use the exact bytes that were signed. Avoids re-serialization mismatches for fields + /// with complex types (arrays, maps). + pub wire_payload: Option>, + pub transport_encryption: Option, + pub incoming: bool, + /// Delivery progress in `0.0..=1.0`. + pub progress: f64, + /// Whether the direct-delivery resource should be bz2-compressed by the RNS layer. + /// + /// Set optimistically to `true` on outbound construction to mirror Python's + /// `LXMessage.auto_compress`. Gets cleared by + /// [`determine_compression_support`](Self::determine_compression_support) when a peer's + /// announce indicates no `SF_COMPRESSION` capability. + pub auto_compress: bool, + /// Optional per-message delivery / failure callbacks. + pub callbacks: MessageCallbacks, +} + +impl LxMessage { + /// Construct a new outbound message. + pub fn new( + destination_hash: [u8; 16], + source_hash: [u8; 16], + title: &str, + content: &str, + method: DeliveryMethod, + ) -> Self { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + Self { + destination_hash, + source_hash, + title: title.to_string(), + content: content.to_string(), + fields: BTreeMap::new(), + msgpack_field_ids: BTreeSet::new(), + timestamp, + signature: None, + signature_validated: false, + state: MessageState::Generating, + method, + stamp: None, + propagation_stamp: None, + ratchet_id: None, + delivery_attempts: 0, + last_delivery_attempt: 0.0, + hash: None, + message_id: None, + transient_id: None, + stamp_cost: None, + outbound_ticket: None, + stamp_value: None, + unverified_reason: None, + representation: DeliveryRepresentation::Unknown, + transport_encrypted: false, + transport_encryption: None, + wire_payload: None, + incoming: false, + progress: 0.0, + auto_compress: true, + callbacks: MessageCallbacks::default(), + } + } + + /// Update [`auto_compress`](Self::auto_compress) based on a peer's cached announce app_data. + /// + /// Matches Python `LXMessage.determine_compression_support` — LXMessage.py:507-514. + /// Legacy (pre-0.9.6) peers that omit the feature list will have compression disabled. + pub fn determine_compression_support(&mut self, peer_app_data: Option<&[u8]>) { + self.auto_compress = peer_app_data + .map(crate::handlers::compression_support_from_app_data) + .unwrap_or(false); + } + + /// Register a callback fired when this message transitions to `Delivered`. + /// + /// Python reference: `LXMessage.register_delivery_callback` — LXMessage.py:264-265. + pub fn register_delivery_callback(&mut self, callback: F) + where + F: Fn(&LxMessage) + Send + Sync + 'static, + { + self.callbacks.on_delivered = Some(Arc::new(callback)); + } + + /// Register a callback fired when this message transitions to `Failed`. + /// + /// Python reference: `LXMessage.register_failed_callback` — LXMessage.py:267-268. + pub fn register_failed_callback(&mut self, callback: F) + where + F: Fn(&LxMessage) + Send + Sync + 'static, + { + self.callbacks.on_failed = Some(Arc::new(callback)); + } + + /// Fire the registered delivery callback, if any. Idempotent on repeated calls. + pub fn notify_delivered(&self) { + if let Some(ref cb) = self.callbacks.on_delivered { + cb(self); + } + } + + /// Fire the registered failure callback, if any. Idempotent on repeated calls. + pub fn notify_failed(&self) { + if let Some(ref cb) = self.callbacks.on_failed { + cb(self); + } + } + + pub fn set_field(&mut self, field_id: u8, data: Vec) { + self.fields.insert(field_id, data); + self.msgpack_field_ids.remove(&field_id); + } + + /// Set a field value that is already encoded as one complete msgpack value. + /// + /// Use this for native structured LXMF fields. For example, Python/Sideband + /// encode `FIELD_IMAGE` as the value `["webp", image_bytes]`, not as a + /// binary blob containing the bytes of that msgpack array. + pub fn set_msgpack_field(&mut self, field_id: u8, data: Vec) -> Result<(), MessageError> { + let mut cursor = std::io::Cursor::new(&data); + rmpv::decode::read_value(&mut cursor) + .map_err(|e| MessageError::PackFailed(format!("invalid msgpack field: {e}")))?; + if cursor.position() != data.len() as u64 { + return Err(MessageError::PackFailed( + "invalid msgpack field: trailing bytes".to_string(), + )); + } + self.fields.insert(field_id, data); + self.msgpack_field_ids.insert(field_id); + Ok(()) + } + + pub fn get_field(&self, field_id: u8) -> Option<&Vec> { + self.fields.get(&field_id) + } + + /// Pack the payload as `msgpack([timestamp, title, content, fields, ?stamp])`. + pub fn pack_payload(&self) -> Result, MessageError> { + self.pack_payload_inner(self.stamp.as_deref()) + } + + /// Pack the payload for signing, always as a 4-element array with `stamp = None`. + /// + /// Python strips the stamp element before verification and re-packs as 4 elements + /// (LXMessage.py:742-745). Matching that here keeps sign/verify bytes stable. + fn pack_payload_for_signing(&self) -> Result, MessageError> { + self.pack_payload_inner(None) + } + + /// Borrow-serialize the payload — used by both [`Self::pack_payload`] and + /// [`Self::pack_payload_for_signing`] to avoid cloning `title`, `content`, + /// and `fields` for every msgpack pass. The on-wire bytes are identical + /// to what serializing an owned [`MessagePayload`] would produce. + fn pack_payload_inner(&self, stamp: Option<&[u8]>) -> Result, MessageError> { + let payload = MessagePayloadRef { + timestamp: self.timestamp, + title: &self.title, + content: &self.content, + fields: &self.fields, + msgpack_field_ids: &self.msgpack_field_ids, + stamp, + }; + rmp_serde::to_vec(&payload).map_err(|e| MessageError::PackFailed(e.to_string())) + } + + /// Pack the full message for wire transmission as + /// `dest_hash || src_hash || signature || payload` (Python/C++ Propagated format). + pub fn pack(&self) -> Result, MessageError> { + let sig = self.signature.ok_or(MessageError::NotSigned)?; + let payload = self.pack_payload()?; + + let mut packed = + Vec::with_capacity(DESTINATION_LENGTH * 2 + SIGNATURE_LENGTH + payload.len()); + packed.extend_from_slice(&self.destination_hash); + packed.extend_from_slice(&self.source_hash); + packed.extend_from_slice(&sig); + packed.extend_from_slice(&payload); + Ok(packed) + } + + fn message_id_from_payload( + destination_hash: &[u8; 16], + source_hash: &[u8; 16], + payload: &[u8], + ) -> [u8; 32] { + let payload_for_hash = + Self::strip_stamp_from_payload(payload).unwrap_or_else(|| payload.to_vec()); + let mut hashed_part = Vec::with_capacity(DESTINATION_LENGTH * 2 + payload_for_hash.len()); + hashed_part.extend_from_slice(destination_hash); + hashed_part.extend_from_slice(source_hash); + hashed_part.extend_from_slice(&payload_for_hash); + sha256(&hashed_part) + } + + /// Pack a message for propagation delivery. + /// + /// Encrypts everything after the destination hash using `encrypt_fn` (ECIES against the + /// destination identity's public key, matching Python `destination.encrypt()`), then wraps + /// as `msgpack([timestamp, [lxmf_data]])`. Returns `(propagation_packed, transient_id)`. + /// + /// Python reference: LXMessage.py:434-441. + pub fn pack_propagated_encrypted( + &mut self, + encrypt_fn: F, + ) -> Result<(Vec, [u8; 16]), MessageError> + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + let (packed, transient_id, _) = self.pack_propagated_encrypted_inner(encrypt_fn, None)?; + Ok((packed, transient_id)) + } + + /// Pack a propagated message and always append a propagation-node stamp. + /// + /// Python always generates a propagation stamp for `PROPAGATED` messages, + /// even when the target cost is zero. A zero-cost stamp is the all-zero + /// 32-byte value, which still gives propagation nodes the expected wire + /// layout for validation and storage. + pub fn pack_propagated_encrypted_with_stamp( + &mut self, + encrypt_fn: F, + target_cost: u8, + ) -> Result<(Vec, [u8; 16], u32), MessageError> + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + self.pack_propagated_encrypted_inner(encrypt_fn, Some(target_cost)) + } + + fn pack_propagated_encrypted_inner( + &mut self, + encrypt_fn: F, + propagation_stamp_cost: Option, + ) -> Result<(Vec, [u8; 16], u32), MessageError> + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + let packed = self.pack()?; + + let encrypted_data = encrypt_fn(&packed[DESTINATION_LENGTH..])?; + + let mut lxmf_data = Vec::with_capacity(DESTINATION_LENGTH + encrypted_data.len()); + lxmf_data.extend_from_slice(&packed[..DESTINATION_LENGTH]); + lxmf_data.extend_from_slice(&encrypted_data); + + // transient_id is computed before the propagation stamp is appended. + let full = full_hash(&lxmf_data); + let mut tid = [0u8; 16]; + tid.copy_from_slice(&full[..16]); + self.transient_id = Some(tid); + + let mut stamp_value = 0; + if let Some(target_cost) = propagation_stamp_cost + && self.propagation_stamp.is_none() + { + let (stamp, value) = crate::stamper::generate_stamp_raw( + &full, + target_cost, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PN, + ) + .ok_or_else(|| { + MessageError::PackFailed("failed to generate propagation stamp".to_string()) + })?; + self.propagation_stamp = Some(stamp); + stamp_value = value; + } + + if let Some(ref prop_stamp) = self.propagation_stamp { + if stamp_value == 0 { + let workblock = crate::stamper::stamp_workblock_raw( + &full, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PN, + ); + stamp_value = crate::stamper::stamp_value_raw(&workblock, prop_stamp); + } + lxmf_data.extend_from_slice(prop_stamp); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + let entries: [&[u8]; 1] = [&lxmf_data]; + let wrapper = PropagationWrapperRef { + timestamp: now, + entries: &entries, + }; + let propagation_packed = + rmp_serde::to_vec(&wrapper).map_err(|e| MessageError::PackFailed(e.to_string()))?; + + Ok((propagation_packed, tid, stamp_value)) + } + + /// Backward-compatible propagation pack when no destination identity is available. Produces + /// the same wire format as [`Self::pack`]; prefer [`Self::pack_propagated_encrypted`] when + /// encryption is possible. + pub fn pack_propagated(&self) -> Result, MessageError> { + self.pack() + } + + /// Unpack a propagation wrapper `msgpack([timestamp, [lxmf_data, ...]])`, where each entry is + /// `dest_hash(16) + encrypted_data + ?propagation_stamp`. The caller must decrypt and then + /// call [`Self::unpack`] on the decrypted bytes. + /// + /// Python reference: `LXMRouter.lxmf_propagation` / `propagation_resource_concluded`. + pub fn unpack_propagation_wrapper(data: &[u8]) -> Result<(f64, Vec>), MessageError> { + let wrapper: (f64, Vec>) = rmp_serde::from_slice(data) + .map_err(|e| MessageError::UnpackFailed(format!("propagation wrapper: {e}")))?; + Ok(wrapper) + } + + /// Compute `transient_id = full_hash(lxmf_data)[..16]` for a propagation blob. + /// + /// The input must be `dest_hash + encrypted_data` without the propagation stamp; callers must + /// strip the trailing 32-byte stamp first if present. Python computes the transient ID + /// before the stamp is appended. + pub fn compute_propagation_transient_id(lxmf_data: &[u8]) -> [u8; 16] { + let full = full_hash(lxmf_data); + let mut tid = [0u8; 16]; + tid.copy_from_slice(&full[..16]); + tid + } + + /// Unpack a wire message: `dest_hash(16) + src_hash(16) + signature(64) + msgpack_payload`. + pub fn unpack(data: &[u8]) -> Result { + let min_size = DESTINATION_LENGTH * 2 + SIGNATURE_LENGTH; + if data.len() < min_size + 1 { + return Err(MessageError::TooShort(data.len())); + } + + let mut dest_hash = [0u8; 16]; + dest_hash.copy_from_slice(&data[..16]); + + let mut src_hash = [0u8; 16]; + src_hash.copy_from_slice(&data[16..32]); + + let mut signature = [0u8; 64]; + signature.copy_from_slice(&data[32..96]); + + let payload_data = &data[96..]; + let payload = Self::unpack_payload_rmpv(payload_data)?; + + let hash = Self::message_id_from_payload(&dest_hash, &src_hash, payload_data); + + let mut msg = Self { + destination_hash: dest_hash, + source_hash: src_hash, + title: payload.title, + content: payload.content, + fields: payload.fields, + msgpack_field_ids: payload.msgpack_field_ids, + timestamp: payload.timestamp, + signature: Some(signature), + signature_validated: false, + state: MessageState::Generating, + method: DeliveryMethod::Direct, + stamp: payload.stamp, + propagation_stamp: None, + ratchet_id: None, + delivery_attempts: 0, + last_delivery_attempt: 0.0, + hash: Some(hash), + message_id: Some(hash), + transient_id: None, + stamp_cost: None, + outbound_ticket: None, + stamp_value: None, + unverified_reason: None, + representation: DeliveryRepresentation::Unknown, + transport_encrypted: false, + transport_encryption: None, + wire_payload: Some(payload_data.to_vec()), + incoming: true, + progress: 0.0, + auto_compress: true, + callbacks: MessageCallbacks::default(), + }; + + // Default to truncated-message-hash transient_id for direct messages. Propagation + // callers should overwrite via [`compute_propagation_transient_id`]. + let mut tid = [0u8; 16]; + tid.copy_from_slice(&hash[..16]); + msg.transient_id = Some(tid); + + Ok(msg) + } + + /// Parse a msgpack payload via `rmpv`. + /// + /// LXMF fields can hold complex types (e.g. `FIELD_IMAGE` arrays, nested arrays for + /// `FIELD_FILE_ATTACHMENTS`) that `rmp_serde`'s strict typing rejects. `rmpv` accepts any + /// msgpack type and re-serializes complex values to bytes. + fn unpack_payload_rmpv(payload_data: &[u8]) -> Result { + use std::io::Cursor; + let value = rmpv::decode::read_value(&mut Cursor::new(payload_data)) + .map_err(|e| MessageError::UnpackFailed(format!("msgpack decode: {e}")))?; + let arr = value + .as_array() + .ok_or_else(|| MessageError::UnpackFailed("payload is not an array".into()))?; + if arr.len() < 4 { + return Err(MessageError::UnpackFailed(format!( + "payload array too short: {}", + arr.len() + ))); + } + + let timestamp = arr[0] + .as_f64() + .ok_or_else(|| MessageError::UnpackFailed("timestamp is not float".into()))?; + + let title = match &arr[1] { + rmpv::Value::Binary(b) => String::from_utf8_lossy(b).into_owned(), + rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(), + _ => String::new(), + }; + + let content = match &arr[2] { + rmpv::Value::Binary(b) => String::from_utf8_lossy(b).into_owned(), + rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(), + _ => String::new(), + }; + + let mut fields = BTreeMap::new(); + let mut msgpack_field_ids = BTreeSet::new(); + if let Some(map_entries) = arr[3].as_map() { + for (k, v) in map_entries { + // Field keys >= 0x80 pack as negative fixint; accept either encoding. + let key = k + .as_u64() + .map(|v| v as u8) + .or_else(|| k.as_i64().map(|v| v as u8)) + .unwrap_or(0); + let value_bytes = match v { + rmpv::Value::Binary(b) => b.clone(), + other => { + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, other).map_err(|e| { + MessageError::UnpackFailed(format!("field re-serialize: {e}")) + })?; + msgpack_field_ids.insert(key); + buf + } + }; + fields.insert(key, value_bytes); + } + } + + let stamp = if arr.len() > 4 { + match &arr[4] { + rmpv::Value::Binary(b) if b.len() == TICKET_LENGTH || b.len() == 32 => { + Some(b.clone()) + } + // Older Rust builds encoded fixed-size stamps as an array of integers. + rmpv::Value::Array(elems) if elems.len() == TICKET_LENGTH || elems.len() == 32 => { + let mut s = Vec::with_capacity(elems.len()); + for elem in elems { + s.push(elem.as_u64().unwrap_or(0) as u8); + } + Some(s) + } + rmpv::Value::Nil => None, + _ => None, + } + } else { + None + }; + + Ok(MessagePayload { + timestamp, + title, + content, + fields, + msgpack_field_ids, + stamp, + }) + } + + /// Strip the stamp (5th element) from a wire payload to reproduce the bytes Python signed. + /// + /// Returns `None` if the payload already has no stamp — caller should use it as-is. + fn strip_stamp_from_payload(payload: &[u8]) -> Option> { + use std::io::Cursor; + let value = rmpv::decode::read_value(&mut Cursor::new(payload)).ok()?; + let arr = value.as_array()?; + if arr.len() <= 4 { + return None; + } + let stripped = rmpv::Value::Array(arr[..4].to_vec()); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &stripped).ok()?; + Some(buf) + } + + /// Alias for [`Self::unpack`]; all formats share the same + /// `dest_hash + src_hash + sig + payload` layout. + pub fn unpack_propagated(data: &[u8]) -> Result { + Self::unpack(data) + } + + pub fn compute_hash(&mut self) -> Result<[u8; 32], MessageError> { + let payload = self.pack_payload_for_signing()?; + let hash = + Self::message_id_from_payload(&self.destination_hash, &self.source_hash, &payload); + self.hash = Some(hash); + self.message_id = Some(hash); + let mut tid = [0u8; 16]; + tid.copy_from_slice(&hash[..16]); + self.transient_id = Some(tid); + Ok(hash) + } + + /// Sign the message with an identity's signing key. + /// + /// Signed blob: `dest_hash + src_hash + payload + SHA256(dest_hash + src_hash + payload)` + /// (Python LXMessage.py:380-383, shared with C++ ratcom/ratdeck). + pub fn sign( + &mut self, + signing_key: &rns_crypto::ed25519::Ed25519PrivateKey, + ) -> Result<(), MessageError> { + let payload = self.pack_payload_for_signing()?; + + let mut signed_data = Vec::with_capacity(32 + payload.len() + 32); + signed_data.extend_from_slice(&self.destination_hash); + signed_data.extend_from_slice(&self.source_hash); + signed_data.extend_from_slice(&payload); + let message_hash = sha256(&signed_data); + signed_data.extend_from_slice(&message_hash); + + self.signature = Some(signing_key.sign(&signed_data)); + self.hash = Some(message_hash); + self.message_id = Some(message_hash); + self.state = MessageState::Outbound; + Ok(()) + } + + /// Sign the message via an external signing function (hardware-identity variant of + /// [`Self::sign`]). The signed blob is the same as [`Self::sign`]; `sign_fn` must return a + /// 64-byte Ed25519 signature. + pub fn sign_with(&mut self, sign_fn: F) -> Result<(), MessageError> + where + F: FnOnce(&[u8]) -> Result<[u8; 64], E>, + E: std::fmt::Display, + { + let payload = self.pack_payload_for_signing()?; + + let mut signed_data = Vec::with_capacity(32 + payload.len() + 32); + signed_data.extend_from_slice(&self.destination_hash); + signed_data.extend_from_slice(&self.source_hash); + signed_data.extend_from_slice(&payload); + let message_hash = sha256(&signed_data); + signed_data.extend_from_slice(&message_hash); + + let signature = sign_fn(&signed_data) + .map_err(|e| MessageError::PackFailed(format!("signing failed: {e}")))?; + + self.signature = Some(signature); + self.hash = Some(message_hash); + self.message_id = Some(message_hash); + self.state = MessageState::Outbound; + Ok(()) + } + + /// Verify the signature against `verify_key`. Signed blob matches [`Self::sign`]. + pub fn verify(&mut self, verify_key: &rns_crypto::ed25519::Ed25519PublicKey) -> bool { + let sig = match self.signature { + Some(s) => s, + None => return false, + }; + + // For inbound messages with complex fields, use the original wire payload bytes (with + // stamp stripped) to avoid re-serialization mismatches. Python does the same via + // LXMessage.py:742-745. + let payload = if let Some(ref wp) = self.wire_payload { + match Self::strip_stamp_from_payload(wp) { + Some(p) => p, + None => wp.clone(), + } + } else { + match self.pack_payload_for_signing() { + Ok(p) => p, + Err(_) => return false, + } + }; + + let mut signed_data = Vec::with_capacity(32 + payload.len() + 32); + signed_data.extend_from_slice(&self.destination_hash); + signed_data.extend_from_slice(&self.source_hash); + signed_data.extend_from_slice(&payload); + let message_hash = sha256(&signed_data); + signed_data.extend_from_slice(&message_hash); + + let valid = verify_key.verify(&signed_data, &sig).is_ok(); + self.signature_validated = valid; + valid + } + + /// Generate or retrieve the PoW stamp for this message. + /// + /// Priority: + /// 1. If an outbound ticket is set, derive a ticket-based stamp + /// `stamp = truncated_hash(ticket || message_id)`. + /// 2. If no [`stamp_cost`](Self::stamp_cost) is set, no stamp is needed. + /// 3. If a stamp is already cached, return it. + /// 4. Otherwise, generate a PoW stamp matching the required cost. + /// + /// Python reference: LXMessage.py:301-332. + pub fn get_stamp(&mut self) -> Option> { + if let Some(ticket) = self.outbound_ticket + && let Some(message_id) = self.message_id + { + let mut material = Vec::with_capacity(TICKET_LENGTH + 32); + material.extend_from_slice(&ticket); + material.extend_from_slice(&message_id); + let hash = truncated_hash(&material).to_vec(); + self.stamp_value = Some(COST_TICKET); + self.stamp = Some(hash.clone()); + return Some(hash); + } + + // 2. No stamp cost required + if self.stamp_cost.is_none() { + self.stamp_value = None; + return None; + } + + // 3. Stamp already generated + if let Some(stamp) = self.stamp.as_ref() { + return Some(stamp.clone()); + } + + // 4. Generate PoW stamp + let cost = self.stamp_cost.unwrap(); + if let Some(message_id) = self.message_id + && let Some((stamp, value)) = + crate::stamper::generate_stamp(&message_id, cost, STAMP_WORKBLOCK_EXPAND_ROUNDS) + { + self.stamp_value = Some(value as u16); + self.stamp = Some(stamp.to_vec()); + return Some(stamp.to_vec()); + } + + None + } + + /// Validate a stamp on this message. + /// + /// Python reference: LXMessage.py:278-299 + /// + /// Priority order: + /// 1. Check against tickets first: `stamp == truncated_hash(ticket + message_id)` + /// 2. If no ticket match, validate as PoW stamp + pub fn validate_stamp_with_tickets( + &mut self, + target_cost: u8, + tickets: Option<&[Vec]>, + ) -> bool { + let message_id = match self.message_id.or(self.hash) { + Some(id) => id, + None => return false, + }; + + let stamp = match self.stamp.as_deref() { + Some(s) => s, + None => { + // Check tickets even without a stamp — tickets generate stamps + if tickets.is_none() { + return false; + } + return false; + } + }; + + // Ticket-based stamps take precedence over PoW. + if let Some(ticket_list) = tickets { + for ticket in ticket_list { + let mut material = Vec::with_capacity(ticket.len() + 32); + material.extend_from_slice(ticket); + material.extend_from_slice(&message_id); + let expected = truncated_hash(&material); + if stamp == expected.as_ref() { + self.stamp_value = Some(COST_TICKET); + return true; + } + } + } + + let Ok(stamp) = <&[u8; 32]>::try_from(stamp) else { + return false; + }; + let workblock = crate::stamper::stamp_workblock(&message_id, STAMP_WORKBLOCK_EXPAND_ROUNDS); + if crate::stamper::stamp_valid(stamp, target_cost, &workblock) { + self.stamp_value = Some(crate::stamper::stamp_value(stamp, &workblock) as u16); + return true; + } + + false + } + + pub fn cancel(&mut self) { + if self.state == MessageState::Outbound || self.state == MessageState::Sending { + self.state = MessageState::Cancelled; + } + } + + pub fn mark_failed(&mut self) { + self.state = MessageState::Failed; + self.notify_failed(); + } + + pub fn mark_delivered(&mut self) { + self.state = MessageState::Delivered; + self.notify_delivered(); + } + + /// Mark transmission as in progress. + /// + /// Python reference: LXMessage.py:479, 499 — DIRECT and PROPAGATED messages enter SENDING + /// when transmission begins. + pub fn mark_sending(&mut self) { + self.state = MessageState::Sending; + } + + pub fn mark_sent(&mut self) { + self.state = MessageState::Sent; + } + + /// Encode the message as `lxm://`. + /// + /// Python encrypts everything after the destination hash with the destination identity + /// before emitting paper/QR content. The caller supplies that identity encryption closure, + /// keeping this crate independent from any concrete identity store. + /// + /// Python reference: LXMessage.py:443-455 and LXMessage.py:687-705. + pub fn to_paper_uri(&self, encrypt_fn: F) -> Result + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + use base64::Engine; + if self.method != DeliveryMethod::Paper { + return Err(MessageError::PackFailed( + "paper URI requires DeliveryMethod::Paper".to_string(), + )); + } + + let packed = self.pack()?; + let encrypted = encrypt_fn(&packed[DESTINATION_LENGTH..])?; + let mut paper_packed = Vec::with_capacity(DESTINATION_LENGTH + encrypted.len()); + paper_packed.extend_from_slice(&packed[..DESTINATION_LENGTH]); + paper_packed.extend_from_slice(&encrypted); + if paper_packed.len() > PAPER_MDU { + return Err(MessageError::PackFailed(format!( + "paper message exceeds maximum size: {} > {}", + paper_packed.len(), + PAPER_MDU + ))); + } + + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&paper_packed); + Ok(format!("lxm://{encoded}")) + } + + /// Decode an `lxm://` paper URI and return `(destination_hash, encrypted_data)`. + pub fn decode_paper_uri(uri: &str) -> Result<([u8; 16], Vec), MessageError> { + use base64::Engine; + let data = uri + .strip_prefix("lxm://") + .ok_or_else(|| MessageError::InvalidUri("missing lxm:// prefix".to_string()))?; + + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(data) + .map_err(|e| MessageError::InvalidUri(format!("base64url decode failed: {e}")))?; + + if bytes.len() < LXMF_OVERHEAD { + return Err(MessageError::TooShort(bytes.len())); + } + + let mut destination_hash = [0u8; 16]; + destination_hash.copy_from_slice(&bytes[..DESTINATION_LENGTH]); + Ok((destination_hash, bytes[DESTINATION_LENGTH..].to_vec())) + } + + /// Decode an upstream-compatible encrypted `lxm://` paper URI. + /// + /// The caller supplies the local destination identity decryption closure. On success the + /// decrypted wire message is unpacked exactly like Python's `ingest_lxm_uri` path. + pub fn from_paper_uri(uri: &str, decrypt_fn: F) -> Result + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + let (destination_hash, encrypted_data) = Self::decode_paper_uri(uri)?; + let decrypted = decrypt_fn(&encrypted_data)?; + let mut packed = Vec::with_capacity(DESTINATION_LENGTH + decrypted.len()); + packed.extend_from_slice(&destination_hash); + packed.extend_from_slice(&decrypted); + let mut message = Self::unpack(&packed)?; + message.method = DeliveryMethod::Paper; + message.representation = DeliveryRepresentation::Paper; + Ok(message) + } + + /// Alias for [`Self::to_paper_uri`]; returns `None` if packing or encryption fails. + pub fn as_uri(&self, encrypt_fn: F) -> Option + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + self.to_paper_uri(encrypt_fn).ok() + } + + /// Alias for [`Self::from_paper_uri`]. Python reference: LXMessage.py:685-731. + pub fn from_uri(uri: &str, decrypt_fn: F) -> Result + where + F: FnOnce(&[u8]) -> Result, MessageError>, + { + Self::from_paper_uri(uri, decrypt_fn) + } + + /// Pack the message into a container for file storage. + /// + /// Layout is a msgpack map `{state: u8, lxmf_bytes: bin, transport_encrypted: bool, + /// transport_encryption: str?, method: u8}`. + pub fn pack_container(&self) -> Result, MessageError> { + let packed = self.pack()?; + let container = MessageContainer { + state: self.state as u8, + lxmf_bytes: packed, + transport_encrypted: self.transport_encrypted, + transport_encryption: self.transport_encryption.clone(), + method: self.method as u8, + }; + rmp_serde::to_vec(&container).map_err(|e| MessageError::PackFailed(e.to_string())) + } + + /// Unpack a message from a container (Python parity: `unpack_from_file` in LXMessage.py). + pub fn unpack_container(data: &[u8]) -> Result { + let container: MessageContainer = + rmp_serde::from_slice(data).map_err(|e| MessageError::UnpackFailed(e.to_string()))?; + + let mut msg = Self::unpack(&container.lxmf_bytes)?; + + msg.state = match container.state { + 0x00 => MessageState::Generating, + 0x01 => MessageState::Outbound, + 0x02 => MessageState::Sending, + 0x04 => MessageState::Sent, + 0x08 => MessageState::Delivered, + 0xFD => MessageState::Rejected, + 0xFE => MessageState::Cancelled, + 0xFF => MessageState::Failed, + _ => MessageState::Generating, + }; + + msg.transport_encrypted = container.transport_encrypted; + msg.transport_encryption = container.transport_encryption; + + msg.method = match container.method { + 0x01 => DeliveryMethod::Opportunistic, + 0x02 => DeliveryMethod::Direct, + 0x03 => DeliveryMethod::Propagated, + 0x05 => DeliveryMethod::Paper, + _ => DeliveryMethod::Direct, + }; + + Ok(msg) + } + + /// Write the message to `directory_path`, named by the message hash. + pub fn write_to_directory(&self, directory_path: &str) -> Result { + let hash = self.hash.ok_or(MessageError::NotSigned)?; + let file_name = rns_crypto::hex_encode(&hash); + let file_path = format!("{directory_path}/{file_name}"); + + let container_data = self.pack_container()?; + std::fs::write(&file_path, container_data) + .map_err(|e| MessageError::PackFailed(format!("write failed: {e}")))?; + + Ok(file_path) + } + + /// Read a message from a container file (Python parity: `unpack_from_file`). + pub fn read_from_file(file_path: &str) -> Result { + let data = std::fs::read(file_path) + .map_err(|e| MessageError::UnpackFailed(format!("read failed: {e}")))?; + Self::unpack_container(&data) + } +} + +/// Container format for on-disk message storage. Python parity: `packed_container` dict. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MessageContainer { + state: u8, + #[serde( + serialize_with = "serialize_bytes", + deserialize_with = "deserialize_bytes" + )] + lxmf_bytes: Vec, + transport_encrypted: bool, + transport_encryption: Option, + method: u8, +} + +fn serialize_bytes(data: &[u8], serializer: S) -> Result { + serializer.serialize_bytes(data) +} + +fn deserialize_bytes<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct BytesVisitor; + impl<'de> serde::de::Visitor<'de> for BytesVisitor { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("byte array") + } + fn visit_bytes(self, v: &[u8]) -> Result, E> { + Ok(v.to_vec()) + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut bytes = Vec::new(); + while let Some(b) = seq.next_element::()? { + bytes.push(b); + } + Ok(bytes) + } + } + deserializer.deserialize_any(BytesVisitor) +} + +fn serialize_optional_bytes( + stamp: &Option>, + serializer: S, +) -> Result { + match stamp { + Some(bytes) => serializer.serialize_some(&BinBytes(bytes)), + None => serializer.serialize_none(), + } +} + +fn deserialize_optional_bytes<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + struct OptionalBytesVisitor; + impl<'de> serde::de::Visitor<'de> for OptionalBytesVisitor { + type Value = Option>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("optional byte string") + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D2, + ) -> Result { + deserialize_bytes(deserializer).map(Some) + } + } + + deserializer.deserialize_option(OptionalBytesVisitor) +} + +/// Borrowing twin of [`MessagePayload`] used by `pack_payload` / +/// `pack_payload_for_signing` to avoid cloning `title`, `content`, and +/// `fields` on every msgpack pass. Produces byte-identical output to +/// serializing an owned [`MessagePayload`] with the same field values. +struct MessagePayloadRef<'a> { + timestamp: f64, + title: &'a str, + content: &'a str, + fields: &'a BTreeMap>, + msgpack_field_ids: &'a BTreeSet, + stamp: Option<&'a [u8]>, +} + +impl serde::Serialize for MessagePayloadRef<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let len = if self.stamp.is_some() { 5 } else { 4 }; + let mut s = serializer.serialize_struct("MessagePayload", len)?; + s.serialize_field("timestamp", &self.timestamp)?; + s.serialize_field("title", &BinStr(self.title))?; + s.serialize_field("content", &BinStr(self.content))?; + s.serialize_field( + "fields", + &FieldMapRef { + fields: self.fields, + msgpack_field_ids: self.msgpack_field_ids, + }, + )?; + if let Some(stamp) = self.stamp { + s.serialize_field("stamp", &BinBytes(stamp))?; + } + s.end() + } +} + +struct BinStr<'a>(&'a str); + +impl serde::Serialize for BinStr<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(self.0.as_bytes()) + } +} + +struct BinBytes<'a>(&'a [u8]); + +impl serde::Serialize for BinBytes<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(self.0) + } +} + +struct PropagationWrapperRef<'a> { + timestamp: f64, + entries: &'a [&'a [u8]], +} + +impl serde::Serialize for PropagationWrapperRef<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeTuple; + let mut tup = serializer.serialize_tuple(2)?; + tup.serialize_element(&self.timestamp)?; + tup.serialize_element(&PropagationEntriesRef(self.entries))?; + tup.end() + } +} + +struct PropagationEntriesRef<'a>(&'a [&'a [u8]]); + +impl serde::Serialize for PropagationEntriesRef<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for entry in self.0 { + seq.serialize_element(&BinBytes(entry))?; + } + seq.end() + } +} + +struct MsgpackFieldValue<'a>(&'a [u8]); + +impl serde::Serialize for MsgpackFieldValue<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::Error; + let value = rmpv::decode::read_value(&mut std::io::Cursor::new(self.0)) + .map_err(S::Error::custom)?; + RmpvValueRef(&value).serialize(serializer) + } +} + +struct RmpvValueRef<'a>(&'a rmpv::Value); + +impl serde::Serialize for RmpvValueRef<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::{Error, SerializeMap, SerializeSeq}; + match self.0 { + rmpv::Value::Nil => serializer.serialize_none(), + rmpv::Value::Boolean(v) => serializer.serialize_bool(*v), + rmpv::Value::Integer(v) => { + if let Some(n) = v.as_i64() { + serializer.serialize_i64(n) + } else if let Some(n) = v.as_u64() { + serializer.serialize_u64(n) + } else { + Err(S::Error::custom("integer outside i64/u64 range")) + } + } + rmpv::Value::F32(v) => serializer.serialize_f32(*v), + rmpv::Value::F64(v) => serializer.serialize_f64(*v), + rmpv::Value::String(v) => serializer.serialize_str(v.as_str().unwrap_or("")), + rmpv::Value::Binary(v) => serializer.serialize_bytes(v), + rmpv::Value::Array(values) => { + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for value in values { + seq.serialize_element(&RmpvValueRef(value))?; + } + seq.end() + } + rmpv::Value::Map(values) => { + let mut map = serializer.serialize_map(Some(values.len()))?; + for (key, value) in values { + map.serialize_entry(&RmpvValueRef(key), &RmpvValueRef(value))?; + } + map.end() + } + rmpv::Value::Ext(_, _) => Err(S::Error::custom("msgpack ext fields are unsupported")), + } + } +} + +struct FieldMapRef<'a> { + fields: &'a BTreeMap>, + msgpack_field_ids: &'a BTreeSet, +} + +impl serde::Serialize for FieldMapRef<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + let mut m = serializer.serialize_map(Some(self.fields.len()))?; + for (k, v) in self.fields { + if self.msgpack_field_ids.contains(k) { + m.serialize_entry(k, &MsgpackFieldValue(v))?; + } else { + m.serialize_entry(k, &BinBytes(v))?; + } + } + m.end() + } +} + +/// Msgpack payload. Title and content serialize as `bin` (not `str`) to match Python's +/// `msgpack.packb(bytes_obj)` and C++'s `mpPackBin()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessagePayload { + pub timestamp: f64, + #[serde( + serialize_with = "serialize_as_bin", + deserialize_with = "deserialize_bin_or_str" + )] + pub title: String, + #[serde( + serialize_with = "serialize_as_bin", + deserialize_with = "deserialize_bin_or_str" + )] + pub content: String, + #[serde(with = "field_map_serde")] + pub fields: BTreeMap>, + #[serde(skip)] + pub msgpack_field_ids: BTreeSet, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_bytes", + deserialize_with = "deserialize_optional_bytes" + )] + pub stamp: Option>, +} + +fn serialize_as_bin(s: &String, serializer: S) -> Result { + serializer.serialize_bytes(s.as_bytes()) +} + +/// Accept either msgpack `bin` or `str` for backwards compatibility with older peers. +fn deserialize_bin_or_str<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + struct BinOrStrVisitor; + impl<'de> serde::de::Visitor<'de> for BinOrStrVisitor { + type Value = String; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("string or binary data") + } + fn visit_str(self, v: &str) -> Result { + Ok(v.to_string()) + } + fn visit_bytes(self, v: &[u8]) -> Result { + String::from_utf8(v.to_vec()).map_err(E::custom) + } + } + deserializer.deserialize_any(BinOrStrVisitor) +} + +/// Custom serde for `BTreeMap>` so field values serialize as msgpack `bin` rather +/// than arrays of ints, matching Python's `msgpack.packb(bytes_obj)`. +mod field_map_serde { + use super::*; + use serde::de::{self, MapAccess, Visitor}; + use serde::ser::SerializeMap; + use serde::{Deserializer, Serializer}; + use std::fmt; + + struct BytesWrapper<'a>(&'a [u8]); + + impl serde::Serialize for BytesWrapper<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(self.0) + } + } + + pub fn serialize(map: &BTreeMap>, serializer: S) -> Result + where + S: Serializer, + { + let mut m = serializer.serialize_map(Some(map.len()))?; + for (k, v) in map { + m.serialize_entry(k, &BytesWrapper(v))?; + } + m.end() + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + struct FieldMapVisitor; + + impl<'de> Visitor<'de> for FieldMapVisitor { + type Value = BTreeMap>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map with u8 keys and byte values") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + const MAX_FIELD_SIZE: usize = 256 * 1024; // 256 KiB per field + const MAX_TOTAL_FIELDS_SIZE: usize = 1024 * 1024; // 1 MiB total + + let mut map = BTreeMap::new(); + let mut total_size = 0usize; + while let Some((key, value)) = access + .next_entry::>() + .map_err(de::Error::custom)? + { + if value.len() > MAX_FIELD_SIZE { + return Err(de::Error::custom("field exceeds maximum size")); + } + total_size += value.len(); + if total_size > MAX_TOTAL_FIELDS_SIZE { + return Err(de::Error::custom("total fields size exceeds maximum")); + } + map.insert(key, value); + } + Ok(map) + } + } + + deserializer.deserialize_map(FieldMapVisitor) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum MessageError { + #[error("message too short: {0} bytes")] + TooShort(usize), + #[error("pack failed: {0}")] + PackFailed(String), + #[error("unpack failed: {0}")] + UnpackFailed(String), + #[error("message not signed")] + NotSigned, + #[error("invalid URI: {0}")] + InvalidUri(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_message() { + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Hello", + "World", + DeliveryMethod::Direct, + ); + assert_eq!(msg.state, MessageState::Generating); + assert_eq!(msg.method, DeliveryMethod::Direct); + assert!(msg.timestamp > 0.0); + assert!(msg.signature.is_none()); + assert!(!msg.signature_validated); + // Mirror Python LXMessage.py:145 default. + assert!(msg.auto_compress); + } + + #[test] + fn test_register_delivery_callback() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + let delivered = Arc::new(AtomicBool::new(false)); + let delivered_clone = delivered.clone(); + msg.register_delivery_callback(move |_m| { + delivered_clone.store(true, Ordering::Relaxed); + }); + + msg.mark_delivered(); + assert_eq!(msg.state, MessageState::Delivered); + assert!(delivered.load(Ordering::Relaxed)); + } + + #[test] + fn test_register_failed_callback() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + let failed = Arc::new(AtomicBool::new(false)); + let failed_clone = failed.clone(); + msg.register_failed_callback(move |_m| { + failed_clone.store(true, Ordering::Relaxed); + }); + + msg.mark_failed(); + assert_eq!(msg.state, MessageState::Failed); + assert!(failed.load(Ordering::Relaxed)); + } + + #[test] + fn test_determine_compression_support() { + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + + let supported = { + use rmpv::Value; + let arr = Value::Array(vec![ + Value::Binary(b"Peer".to_vec()), + Value::from(8u64), + Value::Array(vec![Value::from(crate::constants::SF_COMPRESSION as u64)]), + ]); + crate::encode_value(&arr) + }; + msg.determine_compression_support(Some(&supported)); + assert!(msg.auto_compress); + + // Python LXMF 0.9.6 2-element announce has no feature list. + let python_096 = crate::handlers::get_announce_app_data(Some("Peer"), Some(8)); + msg.determine_compression_support(Some(&python_096)); + assert!(!msg.auto_compress); + + // No peer data at all — auto_compress off (no announce == unknown). + msg.auto_compress = true; + msg.determine_compression_support(None); + assert!(!msg.auto_compress); + } + + #[test] + fn test_pack_payload() { + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Content", + DeliveryMethod::Direct, + ); + let payload = msg.pack_payload().unwrap(); + assert!(!payload.is_empty()); + } + + #[test] + fn test_payload_ref_byte_identical_to_owned() { + // Borrowing serializer must produce the same bytes as the owned one + // for every (with-stamp / without-stamp) variant the code emits. + let mut fields = BTreeMap::new(); + fields.insert(FIELD_IMAGE, b"\x00\x01\x02\x03".to_vec()); + fields.insert(FIELD_AUDIO, b"audio-bytes".to_vec()); + + let cases = [None, Some(vec![0xAB; 32]), Some(vec![0xCD; TICKET_LENGTH])]; + for stamp in cases.iter() { + let owned = MessagePayload { + timestamp: 1700000000.0, + title: "title".to_string(), + content: "content body \u{1F600}".to_string(), + fields: fields.clone(), + msgpack_field_ids: BTreeSet::new(), + stamp: stamp.clone(), + }; + let borrowed = MessagePayloadRef { + timestamp: 1700000000.0, + title: "title", + content: "content body \u{1F600}", + fields: &fields, + msgpack_field_ids: &BTreeSet::new(), + stamp: stamp.as_deref(), + }; + let owned_bytes = rmp_serde::to_vec(&owned).unwrap(); + let borrowed_bytes = rmp_serde::to_vec(&borrowed).unwrap(); + assert_eq!( + owned_bytes, + borrowed_bytes, + "borrow-serializer drifted for stamp={:?}", + stamp.is_some() + ); + } + } + + #[test] + fn test_sign_and_verify() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let pub_key = key.public_key(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Signed Message", + "Content here", + DeliveryMethod::Direct, + ); + + msg.sign(&key).unwrap(); + assert!(msg.signature.is_some()); + assert_eq!(msg.state, MessageState::Outbound); + + assert!(msg.verify(&pub_key)); + assert!(msg.signature_validated); + } + + #[test] + fn test_sign_verify_wrong_key() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let wrong_key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let wrong_pub = wrong_key.public_key(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Content", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + assert!(!msg.verify(&wrong_pub)); + } + + #[test] + fn test_pack_unpack_roundtrip() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Round Trip", + "Full content test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + + let packed = msg.pack().unwrap(); + assert!(packed.len() > 96); + let unpacked = LxMessage::unpack(&packed).unwrap(); + + assert_eq!(unpacked.destination_hash, msg.destination_hash); + assert_eq!(unpacked.source_hash, msg.source_hash); + assert_eq!(unpacked.title, msg.title); + assert_eq!(unpacked.content, msg.content); + assert_eq!(unpacked.signature, msg.signature); + assert!(unpacked.hash.is_some()); + assert!(unpacked.transient_id.is_some()); + } + + #[test] + fn test_pack_propagated_unpack_roundtrip() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Propagated Round Trip", + "Full content test", + DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + + let packed = msg.pack_propagated().unwrap(); + let unpacked = LxMessage::unpack_propagated(&packed).unwrap(); + + assert_eq!(unpacked.destination_hash, [0xAA; 16]); + assert_eq!(unpacked.source_hash, [0xBB; 16]); + assert_eq!(unpacked.title, msg.title); + assert_eq!(unpacked.content, msg.content); + } + + #[test] + fn test_fields() { + let mut msg = LxMessage::new([0; 16], [0; 16], "", "", DeliveryMethod::Direct); + msg.set_field(FIELD_IMAGE, vec![1, 2, 3]); + assert_eq!(msg.get_field(FIELD_IMAGE), Some(&vec![1, 2, 3])); + assert_eq!(msg.get_field(FIELD_AUDIO), None); + } + + #[test] + fn test_msgpack_field_serializes_as_native_value() { + use std::io::Cursor; + + let image_value = rmpv::Value::Array(vec![ + rmpv::Value::String("png".into()), + rmpv::Value::Binary(vec![0x89, b'P', b'N', b'G']), + ]); + let mut image_bytes = Vec::new(); + rmpv::encode::write_value(&mut image_bytes, &image_value).unwrap(); + + let mut msg = LxMessage::new( + [0x11; 16], + [0x22; 16], + "Image", + "Has image", + DeliveryMethod::Direct, + ); + msg.set_msgpack_field(FIELD_IMAGE, image_bytes.clone()) + .unwrap(); + + let payload = msg.pack_payload().unwrap(); + let value = rmpv::decode::read_value(&mut Cursor::new(&payload)).unwrap(); + let arr = value.as_array().unwrap(); + let fields = arr[3].as_map().unwrap(); + let (_, field_value) = fields + .iter() + .find(|(k, _)| k.as_u64() == Some(FIELD_IMAGE as u64)) + .expect("FIELD_IMAGE present"); + assert!( + field_value.as_array().is_some(), + "FIELD_IMAGE must be an LXMF array value, not a bin-wrapped msgpack blob" + ); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + msg.sign(&key).unwrap(); + let packed = msg.pack().unwrap(); + let unpacked = LxMessage::unpack(&packed).unwrap(); + assert_eq!(unpacked.get_field(FIELD_IMAGE), Some(&image_bytes)); + assert!(unpacked.msgpack_field_ids.contains(&FIELD_IMAGE)); + } + + #[test] + fn test_file_attachment_field_serializes_as_native_lxmf_shape() { + use std::io::Cursor; + + let attachment_value = rmpv::Value::Array(vec![rmpv::Value::Array(vec![ + rmpv::Value::String("note.txt".into()), + rmpv::Value::Binary(b"hello".to_vec()), + ])]); + let mut attachment_bytes = Vec::new(); + rmpv::encode::write_value(&mut attachment_bytes, &attachment_value).unwrap(); + + let mut msg = LxMessage::new( + [0x11; 16], + [0x22; 16], + "File", + "Has file", + DeliveryMethod::Direct, + ); + msg.set_msgpack_field(FIELD_FILE_ATTACHMENTS, attachment_bytes.clone()) + .unwrap(); + + let payload = msg.pack_payload().unwrap(); + let value = rmpv::decode::read_value(&mut Cursor::new(&payload)).unwrap(); + let arr = value.as_array().unwrap(); + let fields = arr[3].as_map().unwrap(); + let (_, field_value) = fields + .iter() + .find(|(k, _)| k.as_u64() == Some(FIELD_FILE_ATTACHMENTS as u64)) + .expect("FIELD_FILE_ATTACHMENTS present"); + let attachment = field_value.as_array().unwrap()[0].as_array().unwrap(); + assert_eq!(attachment[0].as_str(), Some("note.txt")); + assert_eq!(attachment[1].as_slice(), Some(&b"hello"[..])); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + msg.sign(&key).unwrap(); + let packed = msg.pack().unwrap(); + let unpacked = LxMessage::unpack(&packed).unwrap(); + assert_eq!( + unpacked.get_field(FIELD_FILE_ATTACHMENTS), + Some(&attachment_bytes) + ); + assert!(unpacked.msgpack_field_ids.contains(&FIELD_FILE_ATTACHMENTS)); + } + + #[test] + fn test_state_transitions() { + let mut msg = LxMessage::new([0; 16], [0; 16], "", "", DeliveryMethod::Direct); + assert_eq!(msg.state, MessageState::Generating); + + msg.state = MessageState::Outbound; + msg.cancel(); + assert_eq!(msg.state, MessageState::Cancelled); + } + + #[test] + fn test_base64url_roundtrip() { + use base64::Engine; + let data = b"Hello, LXMF World! This is a test message."; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data); + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&encoded) + .unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_paper_uri() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0x11; 16], + [0x22; 16], + "Paper", + "Message", + DeliveryMethod::Paper, + ); + msg.sign(&key).unwrap(); + + let uri = msg + .to_paper_uri(|plaintext| Ok(plaintext.to_vec())) + .unwrap(); + assert!(uri.starts_with("lxm://")); + + let decoded = + LxMessage::from_paper_uri(&uri, |ciphertext| Ok(ciphertext.to_vec())).unwrap(); + assert_eq!(decoded.destination_hash, msg.destination_hash); + assert_eq!(decoded.title, "Paper"); + assert_eq!(decoded.method, DeliveryMethod::Paper); + assert_eq!(decoded.representation, DeliveryRepresentation::Paper); + } + + #[test] + fn test_paper_uri_uses_identity_encryption() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let recipient = rns_identity::identity::Identity::new(); + + let mut msg = LxMessage::new( + recipient.hash, + [0xBB; 16], + "Paper", + "encrypted", + DeliveryMethod::Paper, + ); + msg.sign(&key).unwrap(); + let plaintext_tail = msg.pack().unwrap()[DESTINATION_LENGTH..].to_vec(); + + let uri = msg + .to_paper_uri(|plaintext| { + recipient + .encrypt(plaintext, None) + .map_err(|e| MessageError::PackFailed(e.to_string())) + }) + .unwrap(); + let (dest_hash, encrypted_tail) = LxMessage::decode_paper_uri(&uri).unwrap(); + assert_eq!(dest_hash, recipient.hash); + assert_ne!(encrypted_tail, plaintext_tail); + + let decoded = LxMessage::from_paper_uri(&uri, |ciphertext| { + recipient + .decrypt(ciphertext, None, false) + .map_err(|e| MessageError::UnpackFailed(e.to_string())) + }) + .unwrap(); + assert_eq!(decoded.destination_hash, recipient.hash); + assert_eq!(decoded.content, "encrypted"); + } + + #[test] + fn test_sign_sets_hash() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Hash", + "Content", + DeliveryMethod::Direct, + ); + assert!(msg.hash.is_none()); + msg.sign(&key).unwrap(); + assert!(msg.hash.is_some()); + // SHA-256(dest_hash || src_hash || payload) + let payload = msg.pack_payload().unwrap(); + let mut hashed_part = Vec::new(); + hashed_part.extend_from_slice(&msg.destination_hash); + hashed_part.extend_from_slice(&msg.source_hash); + hashed_part.extend_from_slice(&payload); + let expected = rns_crypto::sha::sha256(&hashed_part); + assert_eq!(msg.hash.unwrap(), expected); + } + + #[test] + fn test_unpack_sets_python_message_id() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Hash", + "Content", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + let expected = msg.hash.unwrap(); + + let unpacked = LxMessage::unpack(&msg.pack().unwrap()).unwrap(); + + assert_eq!(unpacked.hash, Some(expected)); + assert_eq!(unpacked.message_id, Some(expected)); + } + + #[test] + fn test_unpack_hash_strips_stamp() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Stamped", + "Content", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + let expected = msg.hash.unwrap(); + msg.stamp = Some(vec![0x42; 32]); + + let unpacked = LxMessage::unpack(&msg.pack().unwrap()).unwrap(); + + assert_eq!(unpacked.hash, Some(expected)); + assert_eq!(unpacked.message_id, Some(expected)); + } + + #[test] + fn test_sign_verify_with_message_hash_appended() { + // Signed data = dest_hash || src_hash || payload || SHA256(dest_hash || src_hash || payload) + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let pub_key = key.public_key(); + + let mut msg = LxMessage::new( + [0x11; 16], + [0x22; 16], + "Test", + "Body", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + + let payload = msg.pack_payload().unwrap(); + let mut hashed_part = Vec::new(); + hashed_part.extend_from_slice(&msg.destination_hash); + hashed_part.extend_from_slice(&msg.source_hash); + hashed_part.extend_from_slice(&payload); + let message_hash = rns_crypto::sha::sha256(&hashed_part); + let mut signed_data = hashed_part; + signed_data.extend_from_slice(&message_hash); + + let sig = msg.signature.unwrap(); + assert!(pub_key.verify(&signed_data, &sig).is_ok()); + assert!(msg.verify(&pub_key)); + } + + #[test] + fn test_unpack_bin_encoded_title_content() { + // Interop: C++/Python senders emit title/content as msgpack `bin`, not `str`. + // Hand-build a 4-element payload: [timestamp, title(bin), content(bin), fields(map)]. + let mut payload = Vec::new(); + + // fixarray of 4 elements + payload.push(0x94); + + // float64 timestamp + payload.push(0xCB); + payload.extend_from_slice(&1700000000.0_f64.to_be_bytes()); + + // bin8 "Hello" + payload.push(0xC4); + payload.push(5); + payload.extend_from_slice(b"Hello"); + + // bin8 "World body" + payload.push(0xC4); + payload.push(10); + payload.extend_from_slice(b"World body"); + + // fixmap of 0 elements + payload.push(0x80); + + let mut wire = Vec::new(); + wire.extend_from_slice(&[0xAA; 16]); + wire.extend_from_slice(&[0xBB; 16]); + wire.extend_from_slice(&[0x00; 64]); + wire.extend_from_slice(&payload); + + let msg = LxMessage::unpack(&wire).unwrap(); + assert_eq!(msg.title, "Hello"); + assert_eq!(msg.content, "World body"); + assert_eq!(msg.destination_hash, [0xAA; 16]); + assert_eq!(msg.source_hash, [0xBB; 16]); + } + + #[test] + fn test_unpack_too_short() { + assert!(LxMessage::unpack(&[0; 10]).is_err()); + // 96-byte header alone is too short -- payload is required + assert!(LxMessage::unpack(&[0; 96]).is_err()); + } + + #[test] + fn test_pack_unpack_container_roundtrip() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Container Test", + "Content for container", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + msg.state = MessageState::Delivered; + msg.transport_encrypted = true; + msg.transport_encryption = Some("Curve25519".to_string()); + + let container_data = msg.pack_container().unwrap(); + assert!(!container_data.is_empty()); + + let unpacked = LxMessage::unpack_container(&container_data).unwrap(); + assert_eq!(unpacked.state, MessageState::Delivered); + assert_eq!(unpacked.method, DeliveryMethod::Direct); + assert!(unpacked.transport_encrypted); + assert_eq!(unpacked.transport_encryption.as_deref(), Some("Curve25519")); + assert_eq!(unpacked.title, "Container Test"); + assert_eq!(unpacked.content, "Content for container"); + } + + #[test] + fn test_pack_unpack_container_method_variants() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + for method in [ + DeliveryMethod::Paper, + DeliveryMethod::Direct, + DeliveryMethod::Propagated, + ] { + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", method); + msg.sign(&key).unwrap(); + let unpacked = LxMessage::unpack_container(&msg.pack_container().unwrap()).unwrap(); + assert_eq!( + unpacked.method, method, + "method round-trip for {:?}", + method + ); + } + } + + #[test] + fn test_new_message_fields() { + let msg = LxMessage::new([0; 16], [0; 16], "", "", DeliveryMethod::Direct); + assert_eq!(msg.representation, DeliveryRepresentation::Unknown); + assert!(msg.unverified_reason.is_none()); + assert!(!msg.transport_encrypted); + assert!(!msg.incoming); + assert_eq!(msg.progress, 0.0); + } + + #[test] + fn test_compute_hash() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Hash Test", + "Content", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + let hash = msg.compute_hash().unwrap(); + assert_ne!(hash, [0u8; 32]); + assert!(msg.transient_id.is_some()); + } + + #[test] + fn test_sign_sets_message_id() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Content", + DeliveryMethod::Direct, + ); + assert!(msg.message_id.is_none()); + msg.sign(&key).unwrap(); + assert!(msg.message_id.is_some()); + assert_eq!(msg.message_id, msg.hash); + } + + #[test] + fn test_pack_propagated_encrypted() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Propagated Encrypted", + "Content body here", + DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + + let (propagation_packed, tid) = msg + .pack_propagated_encrypted(|plaintext| { + // Marker-byte prepend stands in for real encryption + let mut out = vec![0xFF]; + out.extend_from_slice(plaintext); + Ok(out) + }) + .unwrap(); + + assert!(!propagation_packed.is_empty()); + assert_eq!(msg.transient_id, Some(tid)); + assert_ne!(tid, [0u8; 16]); + + let (ts, entries) = LxMessage::unpack_propagation_wrapper(&propagation_packed).unwrap(); + assert!(ts > 0.0); + assert_eq!(entries.len(), 1); + + let raw_wrapper = + rmpv::decode::read_value(&mut std::io::Cursor::new(&propagation_packed)).unwrap(); + match raw_wrapper { + rmpv::Value::Array(items) => match &items[1] { + rmpv::Value::Array(entries) => { + assert!(matches!(entries.first(), Some(rmpv::Value::Binary(_)))); + } + other => panic!("propagation entries should be msgpack array, got {other:?}"), + }, + other => panic!("propagation wrapper should be msgpack array, got {other:?}"), + } + + let lxmf_data = &entries[0]; + assert_eq!(&lxmf_data[..16], &[0xAA; 16]); + } + + #[test] + fn test_propagation_transient_id() { + let lxmf_data = vec![0xAA; 100]; + let tid = LxMessage::compute_propagation_transient_id(&lxmf_data); + assert_eq!(tid.len(), 16); + + let tid2 = LxMessage::compute_propagation_transient_id(&lxmf_data); + assert_eq!(tid, tid2); + + let other_data = vec![0xBB; 100]; + let tid3 = LxMessage::compute_propagation_transient_id(&other_data); + assert_ne!(tid, tid3); + } + + #[test] + fn test_propagation_transient_id_matches_full_hash() { + use rns_crypto::sha::full_hash; + + let lxmf_data = vec![0x42; 200]; + let tid = LxMessage::compute_propagation_transient_id(&lxmf_data); + let expected_full = full_hash(&lxmf_data); + assert_eq!(&tid[..], &expected_full[..16]); + } + + #[test] + fn test_get_stamp_with_ticket() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Ticket", + "Test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + + let ticket = [0x42; 16]; + msg.outbound_ticket = Some(ticket); + + let stamp = msg.get_stamp(); + assert!(stamp.is_some()); + assert_eq!(msg.stamp_value, Some(COST_TICKET)); + + // stamp = truncated_hash(ticket || message_id) + let message_id = msg.message_id.unwrap(); + let mut material = Vec::new(); + material.extend_from_slice(&ticket); + material.extend_from_slice(&message_id); + let expected = truncated_hash(&material).to_vec(); + assert_eq!(stamp.unwrap(), expected); + } + + #[test] + fn test_get_stamp_no_cost() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "NoCost", + "Test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + msg.stamp_cost = None; + + let stamp = msg.get_stamp(); + assert!(stamp.is_none()); + assert!(msg.stamp_value.is_none()); + } + + #[test] + fn test_validate_stamp_with_ticket() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Validate Ticket", + "Test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + + let ticket = vec![0x42; 16]; + let message_id = msg.message_id.unwrap(); + let mut material = Vec::new(); + material.extend_from_slice(&ticket); + material.extend_from_slice(&message_id); + let stamp = truncated_hash(&material).to_vec(); + msg.stamp = Some(stamp); + + let valid = msg.validate_stamp_with_tickets(16, Some(std::slice::from_ref(&ticket))); + assert!(valid); + assert_eq!(msg.stamp_value, Some(COST_TICKET)); + } + + #[test] + fn test_validate_stamp_wrong_ticket() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Wrong Ticket", + "Test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + msg.stamp = Some(vec![0xFF; 32]); + + let valid = msg.validate_stamp_with_tickets(16, Some(&[vec![0x42; 16]])); + // Must fail both the ticket check and the PoW check + assert!(!valid); + } + + #[test] + fn test_validate_stamp_no_stamp() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "No Stamp", + "Test", + DeliveryMethod::Direct, + ); + msg.sign(&key).unwrap(); + msg.stamp = None; + + let valid = msg.validate_stamp_with_tickets(16, None); + assert!(!valid); + } + + #[test] + fn test_pack_propagated_encrypted_with_prop_stamp() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Prop Stamp", + "Content", + DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + msg.propagation_stamp = Some([0xDD; 32]); + + let (propagation_packed, _tid) = msg + .pack_propagated_encrypted(|plaintext| Ok(plaintext.to_vec())) + .unwrap(); + + let (_, entries) = LxMessage::unpack_propagation_wrapper(&propagation_packed).unwrap(); + assert_eq!(entries.len(), 1); + + // lxmf_data = dest_hash(16) || encrypted_data || propagation_stamp(32) + let lxmf_entry = &entries[0]; + assert_eq!(&lxmf_entry[..16], &[0xAA; 16]); + let last_32 = &lxmf_entry[lxmf_entry.len() - 32..]; + assert_eq!(last_32, &[0xDD; 32]); + } + + #[test] + fn test_pack_propagated_encrypted_with_zero_cost_stamp() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Prop Stamp", + "Content", + DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + + let (propagation_packed, _tid, _value) = msg + .pack_propagated_encrypted_with_stamp(|plaintext| Ok(plaintext.to_vec()), 0) + .unwrap(); + + let (_, entries) = LxMessage::unpack_propagation_wrapper(&propagation_packed).unwrap(); + let lxmf_entry = &entries[0]; + assert_eq!(lxmf_entry.len(), msg.pack().unwrap().len() + 32); + assert_eq!(&lxmf_entry[lxmf_entry.len() - 32..], &[0u8; 32]); + assert!(crate::stamper::validate_pn_stamp(lxmf_entry, 0).is_some()); + } + + #[test] + fn test_uri_roundtrip() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Hello via paper", + DeliveryMethod::Paper, + ); + msg.sign(&key).unwrap(); + + let uri = msg.as_uri(|plaintext| Ok(plaintext.to_vec())).unwrap(); + assert!(uri.starts_with("lxm://")); + + let decoded = LxMessage::from_uri(&uri, |ciphertext| Ok(ciphertext.to_vec())).unwrap(); + assert_eq!(decoded.content, "Hello via paper"); + assert_eq!(decoded.title, "Test"); + assert_eq!(decoded.destination_hash, [0xAA; 16]); + assert_eq!(decoded.source_hash, [0xBB; 16]); + } + + #[test] + fn test_from_uri_invalid_prefix() { + let result = + LxMessage::from_uri("https://example.com", |ciphertext| Ok(ciphertext.to_vec())); + assert!(result.is_err()); + } + + #[test] + fn test_from_uri_invalid_base64() { + let result = LxMessage::from_uri("lxm://not-valid-base64!!!", |ciphertext| { + Ok(ciphertext.to_vec()) + }); + assert!(result.is_err()); + } + + use proptest::prelude::*; + + proptest! { + /// Full LxMessage pack → unpack round-trip over the reasonable + /// input space. Explicit `test_pack_unpack_roundtrip` uses one + /// hand-picked case; proptest widens to arbitrary title/content + /// strings, arbitrary dest/source hashes, and all sendable + /// delivery methods. Catches msgpack/serde drift + unicode + /// normalization bugs in the title/content payload. + #[test] + fn proptest_message_pack_unpack_roundtrip( + destination_hash: [u8; 16], + source_hash: [u8; 16], + // Cap string length to keep the test fast; long content is + // already covered by test_pack_unpack_container_roundtrip. + title in ".{0,128}", + content in ".{0,512}", + method_idx in 0u8..=2, + ) { + let method = match method_idx { + 0 => DeliveryMethod::Opportunistic, + 1 => DeliveryMethod::Direct, + _ => DeliveryMethod::Propagated, + }; + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new(destination_hash, source_hash, &title, &content, method); + msg.sign(&key).unwrap(); + + let packed = msg.pack().unwrap(); + let unpacked = LxMessage::unpack(&packed).unwrap(); + + prop_assert_eq!(unpacked.destination_hash, destination_hash); + prop_assert_eq!(unpacked.source_hash, source_hash); + prop_assert_eq!(&unpacked.title, &title); + prop_assert_eq!(&unpacked.content, &content); + prop_assert_eq!(unpacked.signature, msg.signature); + } + } +} diff --git a/crates/lxmf-core/src/peer.rs b/crates/lxmf-core/src/peer.rs new file mode 100644 index 0000000..d5e4c88 --- /dev/null +++ b/crates/lxmf-core/src/peer.rs @@ -0,0 +1,688 @@ +//! LXMF peer propagation node used for store-and-forward sync. +//! +//! Python reference: LXMF/LXMPeer.py. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::constants::*; + +type StoredPeer = ( + Vec, + f64, + u32, + u8, + Option, + Option, + bool, + bool, + Vec>, +); + +/// An LXMF peer propagation node. +#[derive(Debug)] +pub struct LxmPeer { + pub destination_hash: [u8; 16], + pub state: PeerState, + pub sync_strategy: SyncStrategy, + pub last_sync: f64, + unhandled_count: u32, + unhandled_count_cached: bool, + pub unreachable_count: u32, + pub autopeered: bool, + pub stamp_cost: Option, + pub stamp_cost_flexibility: Option, + /// Peering cost used for outbound peering-key generation. + pub peering_cost: u8, + /// Generated peering key `(stamp, value)`. `None` until [`LxmPeer::generate_peering_key`] succeeds. + pub peering_key: Option<([u8; 32], u32)>, + /// Per-transfer propagation limit in KB. + pub propagation_transfer_limit: Option, + /// Per-sync propagation limit in KB. + pub propagation_sync_limit: Option, + pub currently_transferring_messages: Option>, + pub link_alive: bool, + pub created_at: f64, + pub last_heard: f64, + pub alive: bool, + pub peering_timebase: f64, + /// Link establishment rate in bits/sec. + pub link_establishment_rate: f64, + /// Sync transfer rate in bits/sec. + pub sync_transfer_rate: f64, + pub offered: u64, + pub outgoing: u64, + pub incoming: u64, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub last_sync_attempt: f64, + pub next_sync_attempt: f64, + pub sync_backoff: f64, + pub metadata: Option>, + /// Static peers are operator-configured; autopeered peers come from announces. + pub is_static: bool, + /// Message hashes already handled by this peer, for sync filtering. + pub handled_messages: std::collections::HashSet<[u8; 16]>, +} + +impl LxmPeer { + pub fn new(destination_hash: [u8; 16]) -> Self { + let now = now_f64(); + Self { + destination_hash, + state: PeerState::Idle, + sync_strategy: SyncStrategy::default(), + last_sync: 0.0, + unhandled_count: 0, + unhandled_count_cached: false, + unreachable_count: 0, + autopeered: false, + stamp_cost: None, + stamp_cost_flexibility: None, + peering_cost: PEERING_COST, + peering_key: None, + propagation_transfer_limit: Some(PROPAGATION_LIMIT as f64), + propagation_sync_limit: None, + currently_transferring_messages: None, + link_alive: false, + created_at: now, + last_heard: now, + alive: true, + peering_timebase: 0.0, + link_establishment_rate: 0.0, + sync_transfer_rate: 0.0, + offered: 0, + outgoing: 0, + incoming: 0, + rx_bytes: 0, + tx_bytes: 0, + last_sync_attempt: 0.0, + next_sync_attempt: 0.0, + sync_backoff: 0.0, + metadata: None, + is_static: false, + handled_messages: std::collections::HashSet::new(), + } + } + + /// Construct a peer from propagation-node announce data. + /// + /// Announce layout (see Python `LXMRouter.get_propagation_node_app_data`): + /// `[legacy_flag, timebase, node_state, transfer_limit_kb, sync_limit_kb, + /// [stamp_cost, stamp_flex, peering_cost], metadata]`. + pub fn from_announce( + destination_hash: [u8; 16], + timebase: f64, + transfer_limit: Option, + sync_limit: Option, + stamp_cost: Option, + stamp_flexibility: Option, + peering_cost: Option, + ) -> Self { + let mut peer = Self::new(destination_hash); + peer.peering_timebase = timebase; + peer.propagation_transfer_limit = transfer_limit; + peer.propagation_sync_limit = sync_limit; + peer.stamp_cost = stamp_cost; + peer.stamp_cost_flexibility = stamp_flexibility; + peer.peering_cost = peering_cost.unwrap_or(PEERING_COST); + peer.autopeered = true; + peer + } + + /// Effective minimum stamp cost this peer will accept. + pub fn minimum_accepted_stamp_cost(&self) -> u8 { + match self.stamp_cost { + Some(cost) => cost.saturating_sub(PROPAGATION_COST_FLEX), + None => 0, + } + } + + pub fn stamp_costs_known(&self) -> bool { + self.stamp_cost.is_some() && self.stamp_cost_flexibility.is_some() + } + + pub fn add_unhandled_message(&mut self) { + self.unhandled_count_cached = false; + self.unhandled_count += 1; + } + + pub fn unhandled_messages(&self) -> u32 { + self.unhandled_count + } + + pub fn set_unhandled_count(&mut self, count: u32) { + self.unhandled_count = count; + self.unhandled_count_cached = true; + } + + pub fn heard(&mut self) { + self.last_heard = now_f64(); + self.alive = true; + self.unreachable_count = 0; + self.sync_backoff = 0.0; + } + + pub fn add_handled_message(&mut self, hash: &[u8; 16]) { + self.handled_messages.insert(*hash); + } + + pub fn has_handled(&self, hash: &[u8; 16]) -> bool { + self.handled_messages.contains(hash) + } + + /// Serialize peer state, including handled messages, for persistence. + pub fn to_bytes_with_handled(&self) -> Vec { + let handled: Vec> = self.handled_messages.iter().map(|h| h.to_vec()).collect(); + let data = ( + self.destination_hash.to_vec(), + self.last_sync, + self.unreachable_count, + self.peering_cost, + self.stamp_cost, + self.stamp_cost_flexibility, + self.autopeered, + self.is_static, + handled, + ); + rmp_serde::to_vec(&data).unwrap_or_default() + } + + /// Deserialize peer state, including handled messages, from [`to_bytes_with_handled`] output. + /// + /// [`to_bytes_with_handled`]: Self::to_bytes_with_handled + pub fn from_bytes_with_handled(data: &[u8]) -> Option { + let ( + dest_hash_vec, + last_sync, + unreachable_count, + peering_cost, + stamp_cost, + stamp_cost_flexibility, + autopeered, + is_static, + handled_vec, + ): StoredPeer = rmp_serde::from_slice(data).ok()?; + if dest_hash_vec.len() != 16 { + return None; + } + let mut dest_hash = [0u8; 16]; + dest_hash.copy_from_slice(&dest_hash_vec); + let mut peer = Self::new(dest_hash); + peer.last_sync = last_sync; + peer.unreachable_count = unreachable_count; + peer.peering_cost = peering_cost; + peer.stamp_cost = stamp_cost; + peer.stamp_cost_flexibility = stamp_cost_flexibility; + peer.autopeered = autopeered; + peer.is_static = is_static; + peer.handled_messages = handled_vec + .into_iter() + .filter_map(|v| { + if v.len() == 16 { + let mut arr = [0u8; 16]; + arr.copy_from_slice(&v); + Some(arr) + } else { + None + } + }) + .collect(); + Some(peer) + } + + pub fn mark_unreachable(&mut self) { + self.unreachable_count += 1; + let now = now_f64(); + if now - self.last_heard > MAX_UNREACHABLE as f64 { + self.alive = false; + } + } + + pub fn should_sync(&self) -> bool { + if self.state != PeerState::Idle { + return false; + } + + let now = now_f64(); + now > self.next_sync_attempt + } + + pub fn sync_backoff(&self) -> f64 { + self.sync_backoff + } + + /// Peers unseen for [`PEER_STALE_TIME`] are stale and should be rotated to the back of the queue. + pub fn is_stale(&self) -> bool { + let now = now_f64(); + now - self.last_heard > PEER_STALE_TIME as f64 + } + + /// Whether the peering key has been generated and meets [`Self::peering_cost`]. + pub fn peering_key_ready(&self) -> bool { + if let Some((_, value)) = self.peering_key { + value >= self.peering_cost as u32 + } else { + false + } + } + + /// Peering-key value (leading zero bits), if generated. + pub fn peering_key_value(&self) -> Option { + self.peering_key.map(|(_, value)| value) + } + + /// Generate a peering key for this peer. + /// + /// Key material is `peer_identity_hash || our_identity_hash` (16 + 16 bytes), run through the + /// stamp PoW system with [`STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING`] expand rounds. + /// + /// Python reference: `LXMPeer.generate_peering_key` — LXMPeer.py:242-265. + pub fn generate_peering_key( + &mut self, + peer_identity_hash: &[u8; 16], + our_identity_hash: &[u8; 16], + ) -> bool { + if self.peering_key.is_some() { + return true; + } + + let mut key_material = Vec::with_capacity(32); + key_material.extend_from_slice(peer_identity_hash); + key_material.extend_from_slice(our_identity_hash); + + let workblock = crate::stamper::stamp_workblock_raw( + &key_material, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING, + ); + + loop { + let stamp: [u8; 32] = crate::stamper::rand_bytes(); + if crate::stamper::stamp_valid_raw(&stamp, self.peering_cost, &workblock) { + let value = crate::stamper::stamp_value_raw(&workblock, &stamp); + self.peering_key = Some((stamp, value)); + return true; + } + } + } + + /// Acceptance rate (`outgoing / offered`), used for peer rotation decisions. Returns 0.0 if + /// the peer has not yet been offered any messages. + pub fn acceptance_rate(&self) -> f64 { + if self.offered == 0 { + 0.0 + } else { + self.outgoing as f64 / self.offered as f64 + } + } + + pub fn begin_sync(&mut self) { + self.state = PeerState::LinkEstablishing; + self.last_sync_attempt = now_f64(); + self.sync_backoff += SYNC_BACKOFF_STEP as f64; + self.next_sync_attempt = now_f64() + self.sync_backoff; + } + + /// Link-established callback. + /// + /// Records the establishment rate, transitions to [`PeerState::LinkReady`], resets + /// `next_sync_attempt` so sync can proceed immediately, updates `last_heard`, and marks the + /// peer alive. + /// + /// Python reference: LXMPeer.py:530-538. + pub fn link_established(&mut self, _link_id: [u8; 16], establishment_rate: Option) { + if let Some(rate) = establishment_rate { + self.link_establishment_rate = rate; + } + self.state = PeerState::LinkReady; + self.next_sync_attempt = 0.0; + self.last_heard = now_f64(); + self.alive = true; + self.link_alive = true; + } + + /// Link-closed callback: clears the link and transitions to [`PeerState::Idle`]. + /// + /// If the peer was mid-sync, the in-flight transfer list is cleared so backoff logic + /// treats it as a sync failure. + /// + /// Python reference: LXMPeer.py:540-542. + pub fn link_closed(&mut self) { + let was_active = self.state != PeerState::Idle; + self.link_alive = false; + self.state = PeerState::Idle; + + if was_active { + self.currently_transferring_messages = None; + } + } + + pub fn sync_complete(&mut self) { + self.state = PeerState::Idle; + self.last_sync = now_f64(); + self.currently_transferring_messages = None; + self.sync_backoff = 0.0; + self.next_sync_attempt = 0.0; + } + + pub fn sync_failed(&mut self) { + self.state = PeerState::Idle; + self.mark_unreachable(); + self.currently_transferring_messages = None; + } +} + +/// Select the best peer to sync with from a set of candidates. +/// +/// Mirrors Python `sync_peers()`: draw from the fastest [`FASTEST_N_RANDOM_POOL`] alive peers, +/// mix in unknown-speed peers, and fall back to unresponsive peers that have passed their sync +/// backoff. +pub fn select_sync_peer(peers: &[&LxmPeer]) -> Option { + if peers.is_empty() { + return None; + } + + let mut alive_with_unhandled: Vec<(usize, &LxmPeer)> = peers + .iter() + .enumerate() + .filter(|(_, p)| p.alive && p.state == PeerState::Idle && p.unhandled_messages() > 0) + .map(|(i, p)| (i, *p)) + .collect(); + + if !alive_with_unhandled.is_empty() { + alive_with_unhandled.sort_by(|a, b| { + b.1.sync_transfer_rate + .partial_cmp(&a.1.sync_transfer_rate) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let pool_size = alive_with_unhandled.len().min(FASTEST_N_RANDOM_POOL); + + let unknown_speed: Vec<(usize, &LxmPeer)> = alive_with_unhandled + .iter() + .filter(|(_, p)| p.sync_transfer_rate == 0.0) + .copied() + .collect(); + + let mut pool: Vec = alive_with_unhandled[..pool_size] + .iter() + .map(|(i, _)| *i) + .collect(); + for (i, _) in unknown_speed.iter().take(pool_size) { + if !pool.contains(i) { + pool.push(*i); + } + } + + // Deterministic first-of-pool pick; callers that want randomization do it themselves. + return pool.into_iter().next(); + } + + let unresponsive: Vec<(usize, &LxmPeer)> = peers + .iter() + .enumerate() + .filter(|(_, p)| { + !p.alive && p.state == PeerState::Idle && p.unhandled_messages() > 0 && p.should_sync() + }) + .map(|(i, p)| (i, *p)) + .collect(); + + unresponsive.first().map(|(i, _)| *i) +} + +fn now_f64() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_peer() { + let peer = LxmPeer::new([0xAA; 16]); + assert_eq!(peer.state, PeerState::Idle); + assert_eq!(peer.sync_strategy, SyncStrategy::Persistent); + assert!(peer.alive); + assert_eq!(peer.unreachable_count, 0); + } + + #[test] + fn test_minimum_stamp_cost() { + let mut peer = LxmPeer::new([0; 16]); + assert_eq!(peer.minimum_accepted_stamp_cost(), 0); + + peer.stamp_cost = Some(16); + assert_eq!(peer.minimum_accepted_stamp_cost(), 13); + + // cost < flex must saturate at 0. + peer.stamp_cost = Some(2); + assert_eq!(peer.minimum_accepted_stamp_cost(), 0); + } + + #[test] + fn test_mark_unreachable() { + let mut peer = LxmPeer::new([0; 16]); + peer.last_heard = 0.0; + peer.mark_unreachable(); + assert!(!peer.alive); + } + + #[test] + fn test_heard_resets_unreachable() { + let mut peer = LxmPeer::new([0; 16]); + peer.unreachable_count = 2; + + peer.heard(); + assert_eq!(peer.unreachable_count, 0); + assert!(peer.alive); + assert_eq!(peer.sync_backoff, 0.0); + } + + #[test] + fn test_sync_lifecycle() { + let mut peer = LxmPeer::new([0; 16]); + assert!(peer.should_sync()); + + peer.begin_sync(); + assert_eq!(peer.state, PeerState::LinkEstablishing); + assert!(!peer.should_sync()); + + peer.sync_complete(); + assert_eq!(peer.state, PeerState::Idle); + } + + #[test] + fn test_sync_failed() { + let mut peer = LxmPeer::new([0; 16]); + peer.begin_sync(); + peer.last_heard = 0.0; + peer.sync_failed(); + assert_eq!(peer.state, PeerState::Idle); + assert_eq!(peer.unreachable_count, 1); + } + + #[test] + fn test_currently_transferring() { + let mut peer = LxmPeer::new([0; 16]); + assert!(peer.currently_transferring_messages.is_none()); + + peer.currently_transferring_messages = Some(vec![[0xAA; 16], [0xBB; 16]]); + assert_eq!( + peer.currently_transferring_messages.as_ref().unwrap().len(), + 2 + ); + + peer.sync_complete(); + assert!(peer.currently_transferring_messages.is_none()); + } + + #[test] + fn test_add_unhandled() { + let mut peer = LxmPeer::new([0; 16]); + assert_eq!(peer.unhandled_messages(), 0); + + peer.add_unhandled_message(); + peer.add_unhandled_message(); + assert_eq!(peer.unhandled_messages(), 2); + } + + #[test] + fn test_from_announce() { + let peer = LxmPeer::from_announce( + [0xAA; 16], + 1000.0, + Some(256.0), + Some(10240.0), + Some(16), + Some(3), + Some(18), + ); + assert_eq!(peer.peering_timebase, 1000.0); + assert_eq!(peer.propagation_transfer_limit, Some(256.0)); + assert_eq!(peer.propagation_sync_limit, Some(10240.0)); + assert_eq!(peer.stamp_cost, Some(16)); + assert_eq!(peer.stamp_cost_flexibility, Some(3)); + assert_eq!(peer.peering_cost, 18); + assert!(peer.autopeered); + } + + #[test] + fn test_acceptance_rate() { + let mut peer = LxmPeer::new([0; 16]); + assert_eq!(peer.acceptance_rate(), 0.0); + + peer.offered = 10; + peer.outgoing = 5; + assert!((peer.acceptance_rate() - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn test_stamp_costs_known() { + let mut peer = LxmPeer::new([0; 16]); + assert!(!peer.stamp_costs_known()); + + peer.stamp_cost = Some(16); + assert!(!peer.stamp_costs_known()); + + peer.stamp_cost_flexibility = Some(3); + assert!(peer.stamp_costs_known()); + } + + #[test] + fn test_select_sync_peer_basic() { + let mut peer1 = LxmPeer::new([0x01; 16]); + peer1.add_unhandled_message(); + peer1.sync_transfer_rate = 100.0; + + let mut peer2 = LxmPeer::new([0x02; 16]); + peer2.add_unhandled_message(); + peer2.sync_transfer_rate = 200.0; + + let peers: Vec<&LxmPeer> = vec![&peer1, &peer2]; + let selected = select_sync_peer(&peers); + assert!(selected.is_some()); + assert_eq!(selected.unwrap(), 1); + } + + #[test] + fn test_select_sync_peer_empty() { + let peers: Vec<&LxmPeer> = vec![]; + assert!(select_sync_peer(&peers).is_none()); + } + + #[test] + fn test_select_sync_peer_no_unhandled() { + let peer = LxmPeer::new([0x01; 16]); + let peers: Vec<&LxmPeer> = vec![&peer]; + assert!(select_sync_peer(&peers).is_none()); + } + + #[test] + fn test_begin_sync_sets_backoff() { + let mut peer = LxmPeer::new([0; 16]); + assert_eq!(peer.sync_backoff, 0.0); + + peer.begin_sync(); + assert_eq!(peer.sync_backoff, SYNC_BACKOFF_STEP as f64); + + peer.state = PeerState::Idle; + peer.begin_sync(); + assert_eq!(peer.sync_backoff, 2.0 * SYNC_BACKOFF_STEP as f64); + } + + #[test] + fn test_link_established() { + let mut peer = LxmPeer::new([0xAA; 16]); + peer.begin_sync(); + assert_eq!(peer.state, PeerState::LinkEstablishing); + + let link_id = [0xBB; 16]; + peer.link_established(link_id, Some(42.0)); + + assert_eq!(peer.state, PeerState::LinkReady); + assert!(peer.alive); + assert!(peer.link_alive); + assert_eq!(peer.link_establishment_rate, 42.0); + assert_eq!(peer.next_sync_attempt, 0.0); + assert!(peer.last_heard > 0.0); + } + + #[test] + fn test_link_established_no_rate() { + let mut peer = LxmPeer::new([0xAA; 16]); + peer.begin_sync(); + let original_rate = peer.link_establishment_rate; + + peer.link_established([0xBB; 16], None); + + assert_eq!(peer.state, PeerState::LinkReady); + assert_eq!(peer.link_establishment_rate, original_rate); + } + + #[test] + fn test_link_closed_from_idle() { + let mut peer = LxmPeer::new([0xAA; 16]); + peer.link_alive = true; + + peer.link_closed(); + + assert_eq!(peer.state, PeerState::Idle); + assert!(!peer.link_alive); + } + + #[test] + fn test_link_closed_during_sync() { + let mut peer = LxmPeer::new([0xAA; 16]); + peer.begin_sync(); + peer.link_established([0xBB; 16], Some(10.0)); + peer.currently_transferring_messages = Some(vec![[0x01; 16], [0x02; 16]]); + + peer.link_closed(); + + assert_eq!(peer.state, PeerState::Idle); + assert!(!peer.link_alive); + assert!(peer.currently_transferring_messages.is_none()); + } + + #[test] + fn test_link_lifecycle_full_cycle() { + let mut peer = LxmPeer::new([0xAA; 16]); + + peer.begin_sync(); + assert_eq!(peer.state, PeerState::LinkEstablishing); + + peer.link_established([0xBB; 16], Some(100.0)); + assert_eq!(peer.state, PeerState::LinkReady); + assert!(peer.alive); + + peer.sync_complete(); + assert_eq!(peer.state, PeerState::Idle); + + peer.link_closed(); + assert_eq!(peer.state, PeerState::Idle); + assert!(!peer.link_alive); + } +} diff --git a/crates/lxmf-core/src/persist.rs b/crates/lxmf-core/src/persist.rs new file mode 100644 index 0000000..beea7aa --- /dev/null +++ b/crates/lxmf-core/src/persist.rs @@ -0,0 +1,133 @@ +//! Persistent router state — matches Python `/lxmf/` layout. +//! +//! Files: +//! * `outbound_stamp_costs` — `HashMap` +//! * `available_tickets` — `Vec` +//! * `local_deliveries` — `HashMap` +//! * `locally_processed` — `HashMap` +//! +//! All four files are MessagePack-encoded via `rmp-serde`. Missing files are +//! treated as "no prior state" and do not raise errors — a fresh daemon is a +//! valid state. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::Path; + +use crate::router::StampCostEntry; +use crate::ticket::Ticket; + +pub const STAMP_COSTS_FILE: &str = "outbound_stamp_costs"; +pub const TICKETS_FILE: &str = "available_tickets"; +pub const LOCAL_DELIVERIES_FILE: &str = "local_deliveries"; +pub const LOCALLY_PROCESSED_FILE: &str = "locally_processed"; + +fn write_mpk(path: &Path, value: &T) -> io::Result<()> { + let bytes = + rmp_serde::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &bytes)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +fn read_mpk(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) => { + let value = rmp_serde::from_slice(&bytes) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(Some(value)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub fn save_stamp_costs(dir: &Path, costs: &HashMap<[u8; 16], StampCostEntry>) -> io::Result<()> { + write_mpk(&dir.join(STAMP_COSTS_FILE), costs) +} + +pub fn load_stamp_costs(dir: &Path) -> io::Result> { + Ok(read_mpk(&dir.join(STAMP_COSTS_FILE))?.unwrap_or_default()) +} + +pub fn save_tickets(dir: &Path, tickets: &[Ticket]) -> io::Result<()> { + write_mpk(&dir.join(TICKETS_FILE), &tickets) +} + +pub fn load_tickets(dir: &Path) -> io::Result> { + Ok(read_mpk(&dir.join(TICKETS_FILE))?.unwrap_or_default()) +} + +pub fn save_local_deliveries(dir: &Path, ids: &HashMap<[u8; 16], f64>) -> io::Result<()> { + write_mpk(&dir.join(LOCAL_DELIVERIES_FILE), ids) +} + +pub fn load_local_deliveries(dir: &Path) -> io::Result> { + Ok(read_mpk(&dir.join(LOCAL_DELIVERIES_FILE))?.unwrap_or_default()) +} + +pub fn save_locally_processed(dir: &Path, ids: &HashMap<[u8; 16], f64>) -> io::Result<()> { + write_mpk(&dir.join(LOCALLY_PROCESSED_FILE), ids) +} + +pub fn load_locally_processed(dir: &Path) -> io::Result> { + Ok(read_mpk(&dir.join(LOCALLY_PROCESSED_FILE))?.unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn stamp_costs_roundtrip() { + let tmp = TempDir::new().unwrap(); + let mut costs = HashMap::new(); + costs.insert( + [0xAA; 16], + StampCostEntry { + cost: 12, + recorded_at: 1_700_000_000.0, + }, + ); + save_stamp_costs(tmp.path(), &costs).unwrap(); + let loaded = load_stamp_costs(tmp.path()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[&[0xAA; 16]].cost, 12); + } + + #[test] + fn tickets_roundtrip() { + let tmp = TempDir::new().unwrap(); + let tickets = vec![Ticket::new([0x01; 16], [0x02; 16], 9_999.0)]; + save_tickets(tmp.path(), &tickets).unwrap(); + let loaded = load_tickets(tmp.path()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].token, [0x01; 16]); + assert_eq!(loaded[0].destination_hash, [0x02; 16]); + } + + #[test] + fn local_deliveries_roundtrip() { + let tmp = TempDir::new().unwrap(); + let mut ids = HashMap::new(); + ids.insert([0x03; 16], 1_700_000_000.0); + save_local_deliveries(tmp.path(), &ids).unwrap(); + let loaded = load_local_deliveries(tmp.path()).unwrap(); + assert_eq!(loaded.len(), 1); + } + + #[test] + fn missing_file_returns_default() { + let tmp = TempDir::new().unwrap(); + assert!(load_stamp_costs(tmp.path()).unwrap().is_empty()); + assert!(load_tickets(tmp.path()).unwrap().is_empty()); + assert!(load_local_deliveries(tmp.path()).unwrap().is_empty()); + assert!(load_locally_processed(tmp.path()).unwrap().is_empty()); + } +} diff --git a/crates/lxmf-core/src/propagation.rs b/crates/lxmf-core/src/propagation.rs new file mode 100644 index 0000000..bd514c8 --- /dev/null +++ b/crates/lxmf-core/src/propagation.rs @@ -0,0 +1,546 @@ +//! Store-and-forward message storage for LXMF propagation nodes. +//! +//! Mirrors propagation entry management in Python LXMRouter.py. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// A stored propagation entry awaiting collection by peers. +#[derive(Debug, Clone)] +pub struct PropagationEntry { + pub transient_id: [u8; 16], + pub message_hash: [u8; 32], + pub destination_hash: [u8; 16], + pub stored_at: f64, + pub stamp_value: u8, + pub size: usize, + pub collected: bool, +} + +impl PropagationEntry { + pub fn new( + transient_id: [u8; 16], + message_hash: [u8; 32], + destination_hash: [u8; 16], + size: usize, + stamp_value: u8, + ) -> Self { + Self { + transient_id, + message_hash, + destination_hash, + stored_at: now_f64(), + stamp_value, + size, + collected: false, + } + } + + /// Format: `{hex_transient_id}_{timestamp}_{stamp_value}`. + pub fn filename(&self) -> String { + format!( + "{}_{:.0}_{}", + hex_encode(&self.transient_id), + self.stored_at, + self.stamp_value + ) + } + + /// Accepts both the 3-component format and the legacy 2-component + /// `{transient_id}_{timestamp}` form (stamp_value defaults to 0). + pub fn parse_filename(filename: &str) -> Option<([u8; 16], f64, u8)> { + let parts: Vec<&str> = filename.split('_').collect(); + match parts.len() { + 3 => { + let tid = hex_decode_16(parts[0])?; + let ts: f64 = parts[1].parse().ok()?; + let sv: u8 = parts[2].parse().ok()?; + Some((tid, ts, sv)) + } + 2 => { + let tid = hex_decode_16(parts[0])?; + let ts: f64 = parts[1].parse().ok()?; + Some((tid, ts, 0)) + } + _ => None, + } + } +} + +/// Owned by the router actor; no shared access. +#[derive(Debug, Default)] +pub struct PropagationStore { + entries: HashMap<[u8; 16], PropagationEntry>, + total_size: usize, + locally_delivered_ids: HashSet<[u8; 16]>, + locally_processed_ids: HashSet<[u8; 16]>, + ignored_destinations: HashSet<[u8; 16]>, + /// Prioritised destinations receive a 0.1x weight multiplier during culling. + prioritised_destinations: HashSet<[u8; 16]>, + peer_distribution_queue: VecDeque<([u8; 16], Option<[u8; 16]>)>, + /// `None` disables the byte-size cap. + pub storage_limit: Option, +} + +impl PropagationStore { + pub fn new() -> Self { + Self::default() + } + + /// Returns `false` if the destination is in the ignored list. + pub fn insert(&mut self, entry: PropagationEntry) -> bool { + if self.ignored_destinations.contains(&entry.destination_hash) { + return false; + } + self.total_size += entry.size; + self.entries.insert(entry.transient_id, entry); + true + } + + pub fn get(&self, transient_id: &[u8; 16]) -> Option<&PropagationEntry> { + self.entries.get(transient_id) + } + + pub fn contains(&self, transient_id: &[u8; 16]) -> bool { + self.entries.contains_key(transient_id) + } + + pub fn remove(&mut self, transient_id: &[u8; 16]) -> Option { + if let Some(entry) = self.entries.remove(transient_id) { + self.total_size = self.total_size.saturating_sub(entry.size); + Some(entry) + } else { + None + } + } + + pub fn transient_ids(&self) -> Vec<[u8; 16]> { + self.entries.keys().copied().collect() + } + + pub fn entries(&self) -> impl Iterator { + self.entries.values() + } + + pub fn entries_for_destination(&self, dest_hash: &[u8; 16]) -> Vec<&PropagationEntry> { + self.entries + .values() + .filter(|e| &e.destination_hash == dest_hash) + .collect() + } + + pub fn cull_expired(&mut self, max_age_secs: u64) { + let now = now_f64(); + let cutoff = now - max_age_secs as f64; + let removed: Vec<[u8; 16]> = self + .entries + .iter() + .filter(|(_, e)| e.stored_at < cutoff) + .map(|(k, _)| *k) + .collect(); + for id in removed { + self.remove(&id); + } + } + + /// Cull messages by weighted score until total size is within `limit_bytes`. + /// + /// Score = priority_weight * age_weight * size. Evicts highest-weight first + /// (oldest + largest + non-prioritised). Matches Python + /// `clean_message_store()` in LXMRouter.py. + pub fn cull_by_weight(&mut self, limit_bytes: usize) { + if self.total_size <= limit_bytes { + return; + } + + let bytes_needed = self.total_size - limit_bytes; + let now = now_f64(); + + let mut weighted: Vec<([u8; 16], f64)> = self + .entries + .iter() + .map(|(tid, entry)| { + let weight = self.compute_weight(entry, now); + (*tid, weight) + }) + .collect(); + + weighted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let mut bytes_cleaned = 0usize; + let mut to_remove = Vec::new(); + + for (tid, _weight) in &weighted { + if bytes_cleaned >= bytes_needed { + break; + } + if let Some(entry) = self.entries.get(tid) { + bytes_cleaned += entry.size; + to_remove.push(*tid); + } + } + + for tid in to_remove { + self.remove(&tid); + } + } + + /// Matches Python `get_weight()`: + /// age_weight = max(1, (now - received) / 60 / 60 / 24 / 4) + /// priority_weight = 0.1 if prioritised, 1.0 otherwise + /// weight = priority_weight * age_weight * size + pub fn compute_weight(&self, entry: &PropagationEntry, now: f64) -> f64 { + let age_days = (now - entry.stored_at) / 86400.0 / 4.0; + let age_weight = if age_days > 1.0 { age_days } else { 1.0 }; + + let priority_weight = if self + .prioritised_destinations + .contains(&entry.destination_hash) + { + 0.1 + } else { + 1.0 + }; + + priority_weight * age_weight * entry.size as f64 + } + + pub fn get_stamp_value(&self, transient_id: &[u8; 16]) -> Option { + self.entries.get(transient_id).map(|e| e.stamp_value) + } + + pub fn ignore_destination(&mut self, dest_hash: [u8; 16]) { + self.ignored_destinations.insert(dest_hash); + } + + pub fn unignore_destination(&mut self, dest_hash: &[u8; 16]) { + self.ignored_destinations.remove(dest_hash); + } + + pub fn is_destination_ignored(&self, dest_hash: &[u8; 16]) -> bool { + self.ignored_destinations.contains(dest_hash) + } + + pub fn prioritise_destination(&mut self, dest_hash: [u8; 16]) { + self.prioritised_destinations.insert(dest_hash); + } + + pub fn unprioritise_destination(&mut self, dest_hash: &[u8; 16]) { + self.prioritised_destinations.remove(dest_hash); + } + + pub fn mark_locally_delivered(&mut self, transient_id: [u8; 16]) { + self.locally_delivered_ids.insert(transient_id); + } + + pub fn is_locally_delivered(&self, transient_id: &[u8; 16]) -> bool { + self.locally_delivered_ids.contains(transient_id) + } + + pub fn mark_locally_processed(&mut self, transient_id: [u8; 16]) { + self.locally_processed_ids.insert(transient_id); + } + + pub fn is_locally_processed(&self, transient_id: &[u8; 16]) -> bool { + self.locally_processed_ids.contains(transient_id) + } + + pub fn locally_delivered_ids(&self) -> &HashSet<[u8; 16]> { + &self.locally_delivered_ids + } + + pub fn locally_processed_ids(&self) -> &HashSet<[u8; 16]> { + &self.locally_processed_ids + } + + pub fn replace_locally_delivered(&mut self, ids: HashSet<[u8; 16]>) { + self.locally_delivered_ids = ids; + } + + pub fn replace_locally_processed(&mut self, ids: HashSet<[u8; 16]>) { + self.locally_processed_ids = ids; + } + + /// Drop cache entries whose transient IDs no longer exist in `entries` + /// (i.e. were culled). Python removes them once older than + /// MESSAGE_EXPIRY * 6; the caller decides the cutoff here. + pub fn clean_transient_caches(&mut self) { + self.locally_delivered_ids + .retain(|id| self.entries.contains_key(id)); + self.locally_processed_ids + .retain(|id| self.entries.contains_key(id)); + } + + /// `from_peer` is the peer we received this message from, or `None` if it + /// originated locally. + pub fn enqueue_distribution(&mut self, transient_id: [u8; 16], from_peer: Option<[u8; 16]>) { + self.peer_distribution_queue + .push_back((transient_id, from_peer)); + } + + pub fn drain_distribution_queue(&mut self) -> Vec<([u8; 16], Option<[u8; 16]>)> { + self.peer_distribution_queue.drain(..).collect() + } + + pub fn has_pending_distribution(&self) -> bool { + !self.peer_distribution_queue.is_empty() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn total_size(&self) -> usize { + self.total_size + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +fn now_f64() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +pub use rns_crypto::hex_encode; + +fn hex_decode_16(s: &str) -> Option<[u8; 16]> { + if s.len() != 32 { + return None; + } + let bytes: Option> = (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect(); + let bytes = bytes?; + let mut arr = [0u8; 16]; + arr.copy_from_slice(&bytes); + Some(arr) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_entry_filename() { + let entry = PropagationEntry { + transient_id: [0xAA; 16], + message_hash: [0xBB; 32], + destination_hash: [0xCC; 16], + stored_at: 1234567890.0, + stamp_value: 8, + size: 500, + collected: false, + }; + let fname = entry.filename(); + assert!(fname.starts_with("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + let parts: Vec<&str> = fname.split('_').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[2], "8"); + } + + #[test] + fn test_parse_filename_3_component() { + let fname = "aabbccddaabbccddaabbccddaabbccdd_1234567890_16"; + let (tid, ts, sv) = PropagationEntry::parse_filename(fname).unwrap(); + assert_eq!(tid[0], 0xaa); + assert_eq!(ts, 1234567890.0); + assert_eq!(sv, 16); + } + + #[test] + fn test_parse_filename_2_component_legacy() { + let fname = "aabbccddaabbccddaabbccddaabbccdd_1234567890"; + let (tid, ts, sv) = PropagationEntry::parse_filename(fname).unwrap(); + assert_eq!(tid[0], 0xaa); + assert_eq!(ts, 1234567890.0); + assert_eq!(sv, 0); + } + + #[test] + fn test_propagation_store() { + let mut store = PropagationStore::new(); + assert!(store.is_empty()); + + let entry = PropagationEntry::new([0xAA; 16], [0xBB; 32], [0xCC; 16], 500, 8); + store.insert(entry); + + assert_eq!(store.len(), 1); + assert_eq!(store.total_size(), 500); + assert!(store.contains(&[0xAA; 16])); + assert!(!store.contains(&[0x00; 16])); + } + + #[test] + fn test_store_remove() { + let mut store = PropagationStore::new(); + store.insert(PropagationEntry::new( + [0xAA; 16], [0xBB; 32], [0xCC; 16], 500, 8, + )); + store.insert(PropagationEntry::new( + [0xDD; 16], [0xEE; 32], [0xCC; 16], 300, 4, + )); + + assert_eq!(store.total_size(), 800); + + store.remove(&[0xAA; 16]); + assert_eq!(store.len(), 1); + assert_eq!(store.total_size(), 300); + } + + #[test] + fn test_entries_for_destination() { + let mut store = PropagationStore::new(); + let dest1 = [0xAA; 16]; + let dest2 = [0xBB; 16]; + + store.insert(PropagationEntry::new([0x01; 16], [0; 32], dest1, 100, 0)); + store.insert(PropagationEntry::new([0x02; 16], [0; 32], dest1, 200, 0)); + store.insert(PropagationEntry::new([0x03; 16], [0; 32], dest2, 300, 0)); + + assert_eq!(store.entries_for_destination(&dest1).len(), 2); + assert_eq!(store.entries_for_destination(&dest2).len(), 1); + } + + #[test] + fn test_transient_ids() { + let mut store = PropagationStore::new(); + store.insert(PropagationEntry::new([0x01; 16], [0; 32], [0; 16], 100, 0)); + store.insert(PropagationEntry::new([0x02; 16], [0; 32], [0; 16], 200, 0)); + + let ids = store.transient_ids(); + assert_eq!(ids.len(), 2); + } + + #[test] + fn test_ignored_destinations() { + let mut store = PropagationStore::new(); + let ignored_dest = [0xBB; 16]; + let allowed_dest = [0xCC; 16]; + + store.ignore_destination(ignored_dest); + + let entry1 = PropagationEntry::new([0x01; 16], [0; 32], ignored_dest, 100, 0); + assert!(!store.insert(entry1)); + assert_eq!(store.len(), 0); + + let entry2 = PropagationEntry::new([0x02; 16], [0; 32], allowed_dest, 200, 0); + assert!(store.insert(entry2)); + assert_eq!(store.len(), 1); + + store.unignore_destination(&ignored_dest); + let entry3 = PropagationEntry::new([0x03; 16], [0; 32], ignored_dest, 100, 0); + assert!(store.insert(entry3)); + assert_eq!(store.len(), 2); + } + + #[test] + fn test_locally_delivered_ids() { + let mut store = PropagationStore::new(); + let tid = [0xAA; 16]; + + assert!(!store.is_locally_delivered(&tid)); + store.mark_locally_delivered(tid); + assert!(store.is_locally_delivered(&tid)); + } + + #[test] + fn test_locally_processed_ids() { + let mut store = PropagationStore::new(); + let tid = [0xBB; 16]; + + assert!(!store.is_locally_processed(&tid)); + store.mark_locally_processed(tid); + assert!(store.is_locally_processed(&tid)); + } + + #[test] + fn test_cull_by_weight() { + let mut store = PropagationStore::new(); + + let mut entry1 = PropagationEntry::new([0x01; 16], [0; 32], [0xAA; 16], 500, 0); + entry1.stored_at = 1000.0; + store.entries.insert(entry1.transient_id, entry1.clone()); + store.total_size += 500; + + let mut entry2 = PropagationEntry::new([0x02; 16], [0; 32], [0xBB; 16], 300, 0); + entry2.stored_at = now_f64(); + store.entries.insert(entry2.transient_id, entry2.clone()); + store.total_size += 300; + + assert_eq!(store.total_size(), 800); + + store.cull_by_weight(400); + assert!(store.total_size() <= 400); + // Old entry evicted first (higher weight). + assert!(!store.contains(&[0x01; 16])); + } + + #[test] + fn test_peer_distribution_queue() { + let mut store = PropagationStore::new(); + + assert!(!store.has_pending_distribution()); + + store.enqueue_distribution([0xAA; 16], Some([0xBB; 16])); + store.enqueue_distribution([0xCC; 16], None); + + assert!(store.has_pending_distribution()); + + let entries = store.drain_distribution_queue(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, [0xAA; 16]); + assert_eq!(entries[0].1, Some([0xBB; 16])); + assert_eq!(entries[1].0, [0xCC; 16]); + assert!(entries[1].1.is_none()); + + assert!(!store.has_pending_distribution()); + } + + #[test] + fn test_compute_weight() { + let mut store = PropagationStore::new(); + let now = now_f64(); + + let entry = PropagationEntry { + transient_id: [0x01; 16], + message_hash: [0; 32], + destination_hash: [0xAA; 16], + stored_at: now, + stamp_value: 0, + size: 1000, + collected: false, + }; + let w1 = store.compute_weight(&entry, now); + + store.prioritise_destination([0xAA; 16]); + let w2 = store.compute_weight(&entry, now); + assert!(w2 < w1, "prioritised entry should have lower weight"); + + let old_entry = PropagationEntry { + stored_at: now - 30.0 * 86400.0, + ..entry.clone() + }; + store.unprioritise_destination(&[0xAA; 16]); + let w3 = store.compute_weight(&old_entry, now); + assert!(w3 > w1, "old entry should have higher weight"); + } + + #[test] + fn test_get_stamp_value() { + let mut store = PropagationStore::new(); + store.insert(PropagationEntry::new([0xAA; 16], [0; 32], [0; 16], 100, 12)); + + assert_eq!(store.get_stamp_value(&[0xAA; 16]), Some(12)); + assert_eq!(store.get_stamp_value(&[0xBB; 16]), None); + } +} diff --git a/crates/lxmf-core/src/propagation_client.rs b/crates/lxmf-core/src/propagation_client.rs new file mode 100644 index 0000000..398c696 --- /dev/null +++ b/crates/lxmf-core/src/propagation_client.rs @@ -0,0 +1,1288 @@ +//! Client-side propagation node download protocol. +//! +//! Python reference: LXMRouter.py:484-587. +//! +//! Protocol flow: +//! 1. Establish link to the propagation node destination. +//! 2. Identify on the link (LinkIdentify). +//! 3. Request `/get` with `[None, None]` -- server returns available transient IDs. +//! 4. Client sorts into wants/haves. +//! 5. Request `/get` with `[wants, haves, delivery_limit]`. +//! 6. Server returns `[lxmf_data_1, lxmf_data_2, ...]`. +//! 7. Client processes received messages. +//! 8. Final `/get` with `[None, received_ids]` purges them from the server. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use rns_crypto::ed25519::{Ed25519PrivateKey, Ed25519PublicKey}; +use rns_link::link::{CloseReason, Link}; +use rns_protocol::resource::{ + InboundTransfer, MAX_SEGMENTS, MultiSegmentInbound, RANDOM_HASH_SIZE, ResourceError, + TransferAction, +}; +use rns_protocol::resource_adv::ResourceAdvertisement; +use rns_transport::link_messages::DestinationEvent; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use tokio::sync::mpsc; + +use crate::constants::*; +use crate::propagation::hex_encode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropagationClientState { + Idle, + LinkEstablishing, + LinkEstablished, + /// `/get` with `[None, None]` sent. + ListRequested, + /// `/get` with `[wants, haves, limit]` sent. + GetRequested, + /// `/get` with `[None, received_ids]` sent. + PurgeRequested, + Complete, + Failed, +} + +struct SegmentRoute { + original_hash: [u8; 32], + segment_index: usize, +} + +pub struct PropagationClient { + transport_tx: mpsc::Sender, + event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + outbound_propagation_node: Option<[u8; 16]>, + link: Option, + link_id: Option<[u8; 16]>, + pub state: PropagationClientState, + identity_pub: Option<[u8; 64]>, + identity_key: Option, + /// Phase 1 response: transient IDs the server has. + available_messages: Vec>, + /// Messages we already have locally. + local_messages: HashSet>, + /// Phase 2 response: downloaded LXMF message data. + received_messages: Vec>, + /// IDs of messages successfully received (drives the Phase 3 purge). + received_ids: Vec>, + inbound_resources: HashMap<[u8; 32], InboundTransfer>, + inbound_split_resources: HashMap<[u8; 32], MultiSegmentInbound>, + segment_routing: HashMap<[u8; 32], SegmentRoute>, + /// KB per transfer; `None` means unlimited. + delivery_limit: Option, + started_at: Option, + timeout: Duration, + identified: bool, +} + +impl PropagationClient { + pub fn new( + transport_tx: mpsc::Sender, + identity_pub: Option<[u8; 64]>, + identity_key: Option, + ) -> Self { + let (event_tx, event_rx) = mpsc::channel(256); + Self { + transport_tx, + event_tx, + event_rx, + outbound_propagation_node: None, + link: None, + link_id: None, + state: PropagationClientState::Idle, + identity_pub, + identity_key, + available_messages: Vec::new(), + local_messages: HashSet::new(), + received_messages: Vec::new(), + received_ids: Vec::new(), + inbound_resources: HashMap::new(), + inbound_split_resources: HashMap::new(), + segment_routing: HashMap::new(), + delivery_limit: Some(DELIVERY_LIMIT as f64), + started_at: None, + timeout: Duration::from_secs(120), + identified: false, + } + } + + pub fn set_propagation_node(&mut self, dest_hash: [u8; 16]) { + self.outbound_propagation_node = Some(dest_hash); + } + + /// KB per transfer. + pub fn set_delivery_limit(&mut self, limit_kb: f64) { + self.delivery_limit = Some(limit_kb); + } + + pub fn add_local_message(&mut self, transient_id: [u8; 16]) { + self.local_messages.insert(transient_id.to_vec()); + } + + pub fn add_local_message_id(&mut self, transient_id: Vec) { + self.local_messages.insert(transient_id); + } + + pub fn available_messages(&self) -> &[Vec] { + &self.available_messages + } + + pub fn take_received_messages(&mut self) -> Vec> { + std::mem::take(&mut self.received_messages) + } + + pub fn start_download(&mut self) -> bool { + let node_hash = match self.outbound_propagation_node { + Some(h) => h, + None => return false, + }; + + let (link, request_data) = Link::new_initiator(node_hash, 1); + let link_id = link.link_id; + + if let Err(e) = self + .transport_tx + .try_send(TransportMessage::RegisterDestination { + hash: link_id, + app_name: "lxmf.propagation.client".to_string(), + delivery_tx: Some(self.event_tx.clone()), + }) + { + tracing::warn!(err = %e, + "failed to register propagation client destination; download will fail"); + } + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::LinkRequest, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: node_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&request_data); + + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: node_hash, + })); + + self.link = Some(link); + self.link_id = Some(link_id); + self.state = PropagationClientState::LinkEstablishing; + self.started_at = Some(Instant::now()); + self.identified = false; + self.available_messages.clear(); + self.received_messages.clear(); + self.received_ids.clear(); + self.inbound_resources.clear(); + self.inbound_split_resources.clear(); + self.segment_routing.clear(); + true + } + + pub fn drain_events(&mut self, known_identities: &std::collections::HashMap) { + let mut events = Vec::new(); + while let Ok(event) = self.event_rx.try_recv() { + events.push(event); + } + + for event in events { + match event { + DestinationEvent::LinkClosed { link_id } => { + self.handle_link_closed(link_id, None); + } + DestinationEvent::InboundPacket { raw, .. } => { + let (header, data_offset) = match rns_wire::header::PacketHeader::unpack(&raw) { + Ok(h) => h, + Err(_) => continue, + }; + if self.link_id != Some(header.destination_hash) { + continue; + } + let data = if raw.len() > data_offset { + &raw[data_offset..] + } else { + &[] + }; + + match header.context { + rns_wire::context::PacketContext::Lrproof + | rns_wire::context::PacketContext::None + if header.flags.packet_type == rns_wire::flags::PacketType::Proof + || header.context == rns_wire::context::PacketContext::Lrproof => + { + if self.state != PropagationClientState::LinkEstablishing { + continue; + } + let node_hex = self.outbound_propagation_node.map(|h| hex_encode(&h)); + if let Some(node_hex) = node_hex + && let Some(pub_key) = known_identities.get(&node_hex) + { + let ed25519_bytes: [u8; 32] = pub_key[32..64] + .try_into() + .expect("known_identities values are [u8; 64]; slice [32..64] is always 32 bytes"); + if let Ok(verify_key) = Ed25519PublicKey::from_bytes(&ed25519_bytes) + { + self.handle_link_proof(data, &verify_key, &ed25519_bytes); + } + } + } + rns_wire::context::PacketContext::Response => { + if let Some(ref mut link) = self.link + && let Ok((_request_id, response_data)) = link.handle_response(data) + { + self.handle_response_data(&response_data); + } + } + rns_wire::context::PacketContext::ResourceAdv => { + self.handle_resource_advertisement(data); + } + rns_wire::context::PacketContext::Resource => { + self.handle_resource_part(data); + } + rns_wire::context::PacketContext::ResourceHmu => { + self.handle_resource_hmu(data); + } + rns_wire::context::PacketContext::ResourceIcl + | rns_wire::context::PacketContext::ResourceRcl => { + self.handle_resource_cancel(data); + } + rns_wire::context::PacketContext::LinkClose => { + self.handle_link_closed(header.destination_hash, Some(data)); + } + _ => {} + } + } + _ => {} + } + } + } + + fn handle_link_closed(&mut self, link_id: [u8; 16], encrypted_teardown: Option<&[u8]>) -> bool { + if self.link_id != Some(link_id) { + return false; + } + + let Some(link) = self.link.as_mut() else { + return false; + }; + + let verified = match encrypted_teardown { + Some(data) => link.receive_teardown(data), + None => { + link.mark_closed(CloseReason::DestinationClosed); + true + } + }; + + if verified { + self.inbound_resources.clear(); + self.inbound_split_resources.clear(); + self.segment_routing.clear(); + self.state = PropagationClientState::Failed; + } + + verified + } + + fn handle_link_proof( + &mut self, + proof_data: &[u8], + verify_key: &Ed25519PublicKey, + ed25519_pub: &[u8; 32], + ) { + let link = match self.link.as_mut() { + Some(l) => l, + None => return, + }; + + match link.validate_proof(proof_data, verify_key, ed25519_pub) { + Ok(rtt_data) => { + if let Some(link_id) = self.link_id { + let rtt_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Lrrtt, + }; + let mut rtt_raw = rtt_header.pack(); + rtt_raw.extend_from_slice(&rtt_data); + + let _ = + self.transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(rtt_raw), + destination_hash: link_id, + })); + } + self.state = PropagationClientState::LinkEstablished; + } + Err(_) => { + self.state = PropagationClientState::Failed; + } + } + } + + fn handle_response_data(&mut self, response_data: &[u8]) { + match self.state { + PropagationClientState::ListRequested => { + self.handle_list_response(response_data); + } + PropagationClientState::GetRequested => { + self.handle_get_response(response_data); + } + PropagationClientState::PurgeRequested => { + self.handle_purge_response(); + } + _ => {} + } + } + + fn handle_resource_advertisement(&mut self, data: &[u8]) { + let Some(link) = self.link.as_ref() else { + return; + }; + let Ok(plaintext) = link.decrypt(data) else { + self.state = PropagationClientState::Failed; + return; + }; + let Ok(adv) = ResourceAdvertisement::unpack(&plaintext) else { + self.state = PropagationClientState::Failed; + return; + }; + + if !adv.flags.is_response { + return; + } + + if adv.total_segments > 1 { + if adv.total_segments > MAX_SEGMENTS + || adv.segment_index == 0 + || adv.segment_index > adv.total_segments + { + self.state = PropagationClientState::Failed; + return; + } + + let entry = self + .inbound_split_resources + .entry(adv.original_hash) + .or_insert_with(|| MultiSegmentInbound::new(adv.total_segments, adv.original_hash)); + if entry.total_segments != adv.total_segments { + self.state = PropagationClientState::Failed; + return; + } + self.segment_routing.insert( + adv.resource_hash, + SegmentRoute { + original_hash: adv.original_hash, + segment_index: adv.segment_index, + }, + ); + } + + let map_hashes = adv.get_map_hashes(); + let rtt = self + .link + .as_ref() + .and_then(|l| l.rtt) + .unwrap_or(Duration::from_millis(500)); + let mut random_hash = [0u8; RANDOM_HASH_SIZE]; + let copy_len = adv.random_hash.len().min(random_hash.len()); + random_hash[..copy_len].copy_from_slice(&adv.random_hash[..copy_len]); + + let Ok(mut transfer) = InboundTransfer::from_advertisement( + adv.num_parts, + adv.transfer_size, + adv.data_size, + random_hash, + adv.resource_hash, + adv.flags, + map_hashes, + rtt, + ) else { + self.state = PropagationClientState::Failed; + return; + }; + + if let TransferAction::SendRequest(req_data) = transfer.request_next() { + self.send_encrypted_resource_control( + rns_wire::context::PacketContext::ResourceReq, + &req_data, + ); + } + + if let Some(link) = self.link.as_mut() { + link.track_incoming_resource(adv.resource_hash); + } + self.inbound_resources.insert(adv.resource_hash, transfer); + } + + fn handle_resource_part(&mut self, data: &[u8]) { + let mut control_actions = Vec::new(); + let mut completed = None; + + for (resource_hash, transfer) in &mut self.inbound_resources { + let action = transfer.receive_part(data.to_vec()); + match action { + TransferAction::SendHmu(hmu) => { + control_actions.push((rns_wire::context::PacketContext::ResourceHmu, hmu)); + } + TransferAction::SendRequest(req) => { + control_actions.push((rns_wire::context::PacketContext::ResourceReq, req)); + } + TransferAction::Failed(_) => { + self.state = PropagationClientState::Failed; + return; + } + _ => {} + } + + if transfer.resource.is_complete() { + completed = Some(*resource_hash); + } + + if completed.is_some() || !control_actions.is_empty() { + break; + } + } + + for (context, payload) in control_actions { + self.send_encrypted_resource_control(context, &payload); + } + + if let Some(resource_hash) = completed { + self.complete_resource(resource_hash); + } + } + + fn handle_resource_hmu(&mut self, data: &[u8]) { + let Some(link) = self.link.as_ref() else { + return; + }; + let Ok(plaintext) = link.decrypt(data) else { + return; + }; + if plaintext.len() < 32 { + return; + } + + let mut resource_hash = [0u8; 32]; + resource_hash.copy_from_slice(&plaintext[..32]); + let value = match rmpv::decode::read_value(&mut &plaintext[32..]) { + Ok(v) => v, + Err(_) => return, + }; + let Some(arr) = value.as_array() else { + return; + }; + if arr.len() < 2 { + return; + } + let Some(segment) = arr[0].as_u64().map(|v| v as usize) else { + return; + }; + let Some(hashmap_data) = arr[1].as_slice() else { + return; + }; + + let Some(transfer) = self.inbound_resources.get_mut(&resource_hash) else { + return; + }; + match transfer.hashmap_update(segment, hashmap_data) { + TransferAction::SendRequest(req) => { + self.send_encrypted_resource_control( + rns_wire::context::PacketContext::ResourceReq, + &req, + ); + } + TransferAction::Failed(_) => { + self.state = PropagationClientState::Failed; + } + _ => {} + } + } + + fn handle_resource_cancel(&mut self, data: &[u8]) { + let Some(link) = self.link.as_ref() else { + return; + }; + let Ok(plaintext) = link.decrypt(data) else { + return; + }; + if plaintext.len() < 32 { + return; + } + let mut resource_hash = [0u8; 32]; + resource_hash.copy_from_slice(&plaintext[..32]); + self.inbound_resources.remove(&resource_hash); + if let Some(route) = self.segment_routing.remove(&resource_hash) { + self.inbound_split_resources.remove(&route.original_hash); + } + if let Some(link) = self.link.as_mut() { + link.untrack_resource(&resource_hash); + } + self.state = PropagationClientState::Failed; + } + + fn complete_resource(&mut self, resource_hash: [u8; 32]) { + let assembled = { + let Some(link) = self.link.as_ref() else { + return; + }; + let decrypt_fn = |ciphertext: &[u8]| -> Result, ResourceError> { + link.decrypt(ciphertext).map_err(|_| ResourceError::Corrupt) + }; + + let Some(transfer) = self.inbound_resources.get_mut(&resource_hash) else { + return; + }; + match transfer.complete(Some(&decrypt_fn)) { + Ok((assembled, proof)) => { + self.send_resource_proof(&proof); + assembled + } + Err(_) => { + self.state = PropagationClientState::Failed; + return; + } + } + }; + + let route = self.segment_routing.remove(&resource_hash); + if let Some(link) = self.link.as_mut() { + link.untrack_resource(&resource_hash); + } + let metadata = self + .inbound_resources + .get(&resource_hash) + .and_then(|transfer| transfer.resource.metadata.clone()); + self.inbound_resources.remove(&resource_hash); + + if let Some(route) = route { + let mut complete_payload = None; + if let Some(coord) = self.inbound_split_resources.get_mut(&route.original_hash) { + if coord + .set_segment_data(route.segment_index, assembled) + .is_err() + { + self.state = PropagationClientState::Failed; + return; + } + if let Some(meta) = metadata { + coord.set_metadata(meta); + } + if coord.is_complete() { + match coord.reassemble() { + Ok(payload) => complete_payload = Some(payload), + Err(_) => { + self.state = PropagationClientState::Failed; + return; + } + } + } + } + if let Some(payload) = complete_payload { + self.inbound_split_resources.remove(&route.original_hash); + self.handle_resource_response_payload(&payload); + } + } else { + self.handle_resource_response_payload(&assembled); + } + } + + fn handle_resource_response_payload(&mut self, payload: &[u8]) { + let response_data = { + let Some(link) = self.link.as_mut() else { + return; + }; + match link.handle_response_plaintext(payload) { + Ok((_request_id, response_data)) => response_data, + Err(_) => { + self.state = PropagationClientState::Failed; + return; + } + } + }; + self.handle_response_data(&response_data); + } + + fn send_encrypted_resource_control( + &self, + context: rns_wire::context::PacketContext, + plaintext: &[u8], + ) { + if let Some(link) = self.link.as_ref() + && let Ok(encrypted) = link.encrypt(plaintext) + { + self.send_link_packet(context, rns_wire::flags::PacketType::Data, &encrypted); + } + } + + fn send_resource_proof(&self, proof: &[u8]) { + self.send_link_packet( + rns_wire::context::PacketContext::ResourcePrf, + rns_wire::flags::PacketType::Proof, + proof, + ); + } + + fn send_link_packet( + &self, + context: rns_wire::context::PacketContext, + packet_type: rns_wire::flags::PacketType, + payload: &[u8], + ) { + let Some(link_id) = self.link_id else { + return; + }; + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(payload); + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: link_id, + })); + } + + /// Phase 1: parse available transient IDs from the server. + fn handle_list_response(&mut self, response_data: &[u8]) { + let value: rmpv::Value = match rmpv::decode::read_value(&mut &response_data[..]) { + Ok(v) => v, + Err(_) => { + self.state = PropagationClientState::Failed; + return; + } + }; + + if let Some(arr) = value.as_array() { + self.available_messages.clear(); + for item in arr { + if let Some(id_bytes) = item.as_slice() + && matches!(id_bytes.len(), 16 | 32) + { + self.available_messages.push(id_bytes.to_vec()); + } + } + + if self.available_messages.is_empty() { + self.state = PropagationClientState::Complete; + } else { + self.send_get_request(); + } + } else { + self.state = PropagationClientState::Failed; + } + } + + /// Phase 2: parse received message data. + fn handle_get_response(&mut self, response_data: &[u8]) { + let value: rmpv::Value = match rmpv::decode::read_value(&mut &response_data[..]) { + Ok(v) => v, + Err(_) => { + self.state = PropagationClientState::Failed; + return; + } + }; + + if let Some(arr) = value.as_array() { + for item in arr { + if let Some(msg_data) = item.as_slice() { + let tid = rns_crypto::sha::full_hash(msg_data); + self.received_ids.push(tid.to_vec()); + self.received_messages.push(msg_data.to_vec()); + } + } + + if !self.received_ids.is_empty() { + self.send_purge_request(); + } else { + self.state = PropagationClientState::Complete; + } + } else { + self.state = PropagationClientState::Failed; + } + } + + /// Phase 3: mark the download complete. + fn handle_purge_response(&mut self) { + self.state = PropagationClientState::Complete; + } + + pub fn tick(&mut self) { + if let Some(started) = self.started_at + && started.elapsed() > self.timeout + && self.state != PropagationClientState::Idle + && self.state != PropagationClientState::Complete + { + self.cleanup(); + self.state = PropagationClientState::Failed; + return; + } + + match self.state { + PropagationClientState::Idle => {} + PropagationClientState::LinkEstablishing => {} + PropagationClientState::LinkEstablished => { + if !self.identified { + self.send_identify(); + self.identified = true; + } + self.send_list_request(); + } + PropagationClientState::ListRequested + | PropagationClientState::GetRequested + | PropagationClientState::PurgeRequested => {} + PropagationClientState::Complete | PropagationClientState::Failed => { + self.cleanup(); + self.state = PropagationClientState::Idle; + } + } + } + + fn send_identify(&mut self) { + if let (Some(link), Some(link_id)) = (&mut self.link, self.link_id) + && let (Some(pub_key), Some(sign_key)) = (&self.identity_pub, &self.identity_key) + && let Ok(identify_data) = link.identify(pub_key, sign_key) + { + let id_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::LinkIdentify, + }; + let mut id_raw = id_header.pack(); + id_raw.extend_from_slice(&identify_data); + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(id_raw), + destination_hash: link_id, + })); + } + } + + /// Phase 1: `/get` with `[None, None]`. + fn send_list_request(&mut self) { + use rmpv::Value; + + let request_data = { + let array = Value::Array(vec![Value::Nil, Value::Nil]); + crate::encode_value(&array) + }; + + if self.send_get_path_request(&request_data) { + self.state = PropagationClientState::ListRequested; + } else { + self.state = PropagationClientState::Failed; + } + } + + /// Phase 2: `/get` with `[wants, haves, delivery_limit]`. + fn send_get_request(&mut self) { + use rmpv::Value; + + let wants: Vec = self + .available_messages + .iter() + .filter(|id| !self.local_messages.contains(*id)) + .map(|id| Value::Binary(id.clone())) + .collect(); + + // haves are messages we already hold; sending them lets the server purge. + let haves: Vec = self + .available_messages + .iter() + .filter(|id| self.local_messages.contains(*id)) + .map(|id| Value::Binary(id.clone())) + .collect(); + + if wants.is_empty() { + if haves.is_empty() { + self.state = PropagationClientState::Complete; + return; + } + let array = Value::Array(vec![Value::Nil, Value::Array(haves)]); + let buf = crate::encode_value(&array); + if self.send_get_path_request(&buf) { + self.state = PropagationClientState::PurgeRequested; + } else { + self.state = PropagationClientState::Failed; + } + return; + } + + let mut elements = vec![Value::Array(wants), Value::Array(haves)]; + if let Some(limit) = self.delivery_limit { + elements.push(Value::F64(limit)); + } + + let array = Value::Array(elements); + let buf = crate::encode_value(&array); + + if self.send_get_path_request(&buf) { + self.state = PropagationClientState::GetRequested; + } else { + self.state = PropagationClientState::Failed; + } + } + + /// Phase 3: `/get` with `[None, received_ids]`. + fn send_purge_request(&mut self) { + use rmpv::Value; + + let received: Vec = self + .received_ids + .iter() + .map(|id| Value::Binary(id.clone())) + .collect(); + + let array = Value::Array(vec![Value::Nil, Value::Array(received)]); + let buf = crate::encode_value(&array); + + if self.send_get_path_request(&buf) { + self.state = PropagationClientState::PurgeRequested; + } else { + self.state = PropagationClientState::Failed; + } + } + + /// Send a msgpack request to the `MESSAGE_GET_PATH` endpoint; returns `true` + /// if the request was dispatched successfully. + fn send_get_path_request(&mut self, request_data: &[u8]) -> bool { + if let Some(ref mut link) = self.link { + match link.request( + MESSAGE_GET_PATH, + Some(request_data), + Duration::from_secs(60), + ) { + Ok((encrypted, _request_id)) => { + if let Some(link_id) = self.link_id { + let req_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Request, + }; + let mut req_raw = req_header.pack(); + req_raw.extend_from_slice(&encrypted); + let packet_request_id = rns_wire::hash::truncated_packet_hash( + &req_raw, + rns_wire::flags::HeaderType::Header1, + ); + link.update_pending_request_id(&_request_id, packet_request_id); + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + OutboundRequest { + raw: Bytes::from(req_raw), + destination_hash: link_id, + }, + )); + return true; + } + } + Err(_) => return false, + } + } + false + } + + fn cleanup(&mut self) { + self.send_teardown(); + if let Some(link_id) = self.link_id.take() { + let _ = self + .transport_tx + .try_send(TransportMessage::DeregisterDestination { hash: link_id }); + } + self.link = None; + self.inbound_resources.clear(); + self.inbound_split_resources.clear(); + self.segment_routing.clear(); + self.started_at = None; + } + + fn send_teardown(&mut self) { + let Some(link_id) = self.link_id else { + return; + }; + let teardown_data = self + .link + .as_mut() + .and_then(|link| link.teardown(CloseReason::InitiatorClosed)); + if let Some(teardown_data) = teardown_data { + self.send_link_packet( + rns_wire::context::PacketContext::LinkClose, + rns_wire::flags::PacketType::Data, + &teardown_data, + ); + tracing::debug!( + link_id = hex::encode(link_id), + "propagation client link closed" + ); + } + } + + pub fn received_count(&self) -> usize { + self.received_messages.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn active_link_pair(dest_hash: [u8; 16]) -> (Link, Link) { + let responder_key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let responder_pub = responder_key.public_key(); + let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1); + let (mut responder, proof_data) = + Link::new_responder(&request_data, &responder_key, dest_hash, 1).unwrap(); + let rtt_data = initiator + .validate_proof(&proof_data, &responder_pub, &responder_pub.to_bytes()) + .unwrap(); + responder.receive_rtt_packet(&rtt_data).unwrap(); + (initiator, responder) + } + + fn link_data_packet( + link_id: [u8; 16], + context: rns_wire::context::PacketContext, + payload: &[u8], + ) -> Bytes { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(payload); + Bytes::from(raw) + } + + #[test] + fn test_client_creation() { + let (tx, _rx) = mpsc::channel(16); + let client = PropagationClient::new(tx, None, None); + assert_eq!(client.state, PropagationClientState::Idle); + assert_eq!(client.received_count(), 0); + } + + #[test] + fn test_set_propagation_node() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + assert!(client.outbound_propagation_node.is_none()); + + client.set_propagation_node([0xAA; 16]); + assert_eq!(client.outbound_propagation_node, Some([0xAA; 16])); + } + + #[test] + fn test_start_download_no_node() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + assert!(!client.start_download()); + assert_eq!(client.state, PropagationClientState::Idle); + } + + #[test] + fn test_start_download_sends_link_request() { + let (tx, mut rx) = mpsc::channel(64); + let mut client = PropagationClient::new(tx, None, None); + client.set_propagation_node([0xBB; 16]); + + assert!(client.start_download()); + assert_eq!(client.state, PropagationClientState::LinkEstablishing); + assert!(client.link_id.is_some()); + + let reg = rx.try_recv(); + assert!(matches!( + reg.unwrap(), + TransportMessage::RegisterDestination { .. } + )); + let outbound = rx.try_recv(); + assert!(matches!(outbound.unwrap(), TransportMessage::Outbound(_))); + } + + #[test] + fn test_add_local_messages() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + + client.add_local_message([0xAA; 16]); + client.add_local_message([0xBB; 16]); + assert_eq!(client.local_messages.len(), 2); + assert!(client.local_messages.contains(&vec![0xAA; 16])); + client.add_local_message_id(vec![0xCC; 32]); + assert!(client.local_messages.contains(&vec![0xCC; 32])); + } + + #[test] + fn test_set_delivery_limit() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.set_delivery_limit(512.0); + assert_eq!(client.delivery_limit, Some(512.0)); + } + + #[test] + fn test_take_received_messages() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + + client.received_messages.push(vec![0x01, 0x02]); + client.received_messages.push(vec![0x03, 0x04]); + assert_eq!(client.received_count(), 2); + + let messages = client.take_received_messages(); + assert_eq!(messages.len(), 2); + assert_eq!(client.received_count(), 0); + } + + #[test] + fn test_handle_list_response_empty() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.state = PropagationClientState::ListRequested; + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Array(vec![])).unwrap(); + + client.handle_list_response(&buf); + assert_eq!(client.state, PropagationClientState::Complete); + } + + #[test] + fn test_handle_list_response_invalid() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.state = PropagationClientState::ListRequested; + + client.handle_list_response(&[0xFF, 0xFF]); + assert_eq!(client.state, PropagationClientState::Failed); + } + + #[test] + fn test_handle_list_response_accepts_python_full_hash_ids() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.state = PropagationClientState::ListRequested; + + let id32 = vec![0xAB; 32]; + let response = rmpv::Value::Array(vec![rmpv::Value::Binary(id32.clone())]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &response).unwrap(); + + client.handle_list_response(&buf); + assert_eq!(client.available_messages, vec![id32]); + // It accepted the 32-byte ID, then failed only because this unit test + // has no live link on which to send the follow-up `/get`. + assert_eq!(client.state, PropagationClientState::Failed); + } + + #[test] + fn test_handle_get_response_parses_messages() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.set_propagation_node([0xBB; 16]); + client.start_download(); + client.state = PropagationClientState::GetRequested; + + let msg1 = vec![0xAA; 100]; + let msg2 = vec![0xBB; 200]; + let response = rmpv::Value::Array(vec![ + rmpv::Value::Binary(msg1.clone()), + rmpv::Value::Binary(msg2.clone()), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &response).unwrap(); + + client.handle_get_response(&buf); + assert_eq!(client.received_messages.len(), 2); + assert_eq!(client.received_messages[0], msg1); + assert_eq!(client.received_messages[1], msg2); + assert_eq!(client.received_ids.len(), 2); + assert_eq!( + client.received_ids[0], + rns_crypto::sha::full_hash(&msg1).to_vec() + ); + assert_eq!(client.received_ids[0].len(), 32); + } + + #[test] + fn test_handle_get_response_empty() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.state = PropagationClientState::GetRequested; + + let response = rmpv::Value::Array(vec![]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &response).unwrap(); + + client.handle_get_response(&buf); + assert_eq!(client.state, PropagationClientState::Complete); + } + + #[test] + fn test_handle_purge_response() { + let (tx, _rx) = mpsc::channel(16); + let mut client = PropagationClient::new(tx, None, None); + client.state = PropagationClientState::PurgeRequested; + + client.handle_purge_response(); + assert_eq!(client.state, PropagationClientState::Complete); + } + + #[test] + fn test_timeout_fails() { + let (tx, _rx) = mpsc::channel(64); + let mut client = PropagationClient::new(tx, None, None); + client.set_propagation_node([0xCC; 16]); + client.start_download(); + assert_eq!(client.state, PropagationClientState::LinkEstablishing); + + client.timeout = Duration::ZERO; + + client.tick(); + assert_eq!(client.state, PropagationClientState::Failed); + + client.tick(); + assert_eq!(client.state, PropagationClientState::Idle); + } + + #[test] + fn test_cleanup_deregisters() { + let (tx, mut rx) = mpsc::channel(64); + let mut client = PropagationClient::new(tx, None, None); + client.set_propagation_node([0xDD; 16]); + client.start_download(); + while rx.try_recv().is_ok() {} + + client.state = PropagationClientState::Complete; + client.tick(); + + let dereg = rx.try_recv(); + assert!(matches!( + dereg.unwrap(), + TransportMessage::DeregisterDestination { .. } + )); + } + + #[test] + fn test_authenticated_remote_link_close_fails_and_cleans_up() { + let (tx, mut rx) = mpsc::channel(64); + let mut client = PropagationClient::new(tx, None, None); + let node_hash = [0xE1; 16]; + let (link, mut responder_link) = active_link_pair(node_hash); + let link_id = link.link_id; + client.link = Some(link); + client.link_id = Some(link_id); + client.state = PropagationClientState::ListRequested; + client.started_at = Some(Instant::now()); + + let close_body = responder_link + .teardown(CloseReason::InitiatorClosed) + .expect("remote active link emits authenticated teardown"); + client + .event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_body, + ), + interface_id: 0, + }) + .unwrap(); + + client.drain_events(&std::collections::HashMap::new()); + assert_eq!(client.state, PropagationClientState::Failed); + + client.tick(); + assert_eq!(client.state, PropagationClientState::Idle); + assert!(client.link.is_none()); + assert!(matches!( + rx.try_recv().unwrap(), + TransportMessage::DeregisterDestination { hash } if hash == link_id + )); + } + + #[test] + fn test_unauthenticated_link_close_is_ignored() { + let (tx, _rx) = mpsc::channel(64); + let mut client = PropagationClient::new(tx, None, None); + let node_hash = [0xE2; 16]; + let (link, _responder_link) = active_link_pair(node_hash); + let link_id = link.link_id; + client.link = Some(link); + client.link_id = Some(link_id); + client.state = PropagationClientState::ListRequested; + + client + .event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet(link_id, rns_wire::context::PacketContext::LinkClose, &[0u8]), + interface_id: 0, + }) + .unwrap(); + + client.drain_events(&std::collections::HashMap::new()); + assert_eq!(client.state, PropagationClientState::ListRequested); + assert!(client.link.is_some()); + } +} diff --git a/crates/lxmf-core/src/propagation_node.rs b/crates/lxmf-core/src/propagation_node.rs new file mode 100644 index 0000000..c7569b2 --- /dev/null +++ b/crates/lxmf-core/src/propagation_node.rs @@ -0,0 +1,1787 @@ +//! Store-and-forward propagation node with optional disk persistence. +//! +//! Mirrors propagation node management in Python LXMRouter.py. Provides +//! message acceptance with size/duplicate checks, sync offer generation with +//! per-peer filtering, peer persistence (save/load with handled message sets), +//! and expired message culling with orphaned file cleanup. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use crate::constants::*; +use crate::message::LxMessage; +use crate::peer::LxmPeer; +use crate::propagation::{PropagationEntry, PropagationStore, hex_encode}; +use crate::sync::{OfferResponse, SyncGet, SyncOffer, SyncSession}; + +#[derive(Debug, Clone)] +pub struct PropagationNodeConfig { + pub max_storage: usize, + pub max_message_age: u64, + /// Messages below this stamp value are rejected. Matches Python + /// `propagation_stamp_cost` (production default PROPAGATION_COST = 16). + pub min_stamp_cost: u8, + pub peering_cost: u8, + pub max_message_size: usize, +} + +impl Default for PropagationNodeConfig { + fn default() -> Self { + Self { + max_storage: PROPAGATION_LIMIT * 1024 * 1024, + max_message_age: MESSAGE_EXPIRY, + // Disabled by default; set to PROPAGATION_COST for production. + min_stamp_cost: 0, + peering_cost: PEERING_COST, + max_message_size: DELIVERY_LIMIT * 1024, + } + } +} + +pub struct PropagationNode { + config: PropagationNodeConfig, + store: PropagationStore, + sync_sessions: HashMap<[u8; 16], SyncSession>, + pub dest_hash: [u8; 16], + storage_path: Option, + /// Per-peer last offer time, for rate-limiting. + last_offer_times: HashMap<[u8; 16], f64>, +} + +impl PropagationNode { + /// In-memory node (no disk persistence). + pub fn new(config: PropagationNodeConfig, dest_hash: [u8; 16]) -> Self { + Self { + config, + store: PropagationStore::new(), + sync_sessions: HashMap::new(), + dest_hash, + storage_path: None, + last_offer_times: HashMap::new(), + } + } + + pub fn min_stamp_cost(&self) -> u8 { + self.config.min_stamp_cost + } + + pub fn set_min_stamp_cost(&mut self, cost: u8) { + self.config.min_stamp_cost = cost; + } + + /// Disk-backed node. Loads existing messages from `storage_path` on startup. + pub fn with_storage( + config: PropagationNodeConfig, + dest_hash: [u8; 16], + storage_path: PathBuf, + ) -> std::io::Result { + std::fs::create_dir_all(&storage_path)?; + let mut node = Self { + config, + store: PropagationStore::new(), + sync_sessions: HashMap::new(), + dest_hash, + storage_path: Some(storage_path), + last_offer_times: HashMap::new(), + }; + node.load_from_disk()?; + Ok(node) + } + + /// Returns `true` if the message was stored, `false` on duplicate, overflow, + /// pack failure, oversized message, or insufficient stamp. + #[tracing::instrument( + level = "debug", + name = "propagation.accept_message", + skip_all, + fields( + transient_id = message.transient_id.as_ref().map(|tid| hex::encode(&tid[..8])), + size = message.content.len(), + ), + )] + pub fn accept_message(&mut self, message: &LxMessage) -> bool { + let hash = match message.hash { + Some(h) => h, + None => return false, + }; + + let transient_id = rns_crypto::sha::truncated_hash(&hash); + if self.store.contains(&transient_id) { + return false; + } + if self.store.total_size() > self.config.max_storage { + return false; + } + + let packed = match message.pack() { + Ok(p) => p, + Err(_) => return false, + }; + let msg_size = packed.len(); + + if msg_size > self.config.max_message_size { + return false; + } + + // Compute stamp value via HKDF workblock over full_hash(packed) using + // PN expand rounds. Matches Python LXStamper.validate_pn_stamp(). + let sv = if let Some(ref stamp) = message.stamp { + let transient_id_full = rns_crypto::sha::full_hash(&packed); + let workblock = crate::stamper::stamp_workblock_raw( + &transient_id_full, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PN, + ); + if let Ok(stamp) = <&[u8; 32]>::try_from(stamp.as_slice()) { + crate::stamper::stamp_value_raw(&workblock, stamp) as u8 + } else { + 0 + } + } else { + 0 + }; + + if self.config.min_stamp_cost > 0 && sv < self.config.min_stamp_cost { + return false; + } + + let mut entry = + PropagationEntry::new(transient_id, hash, message.destination_hash, msg_size, sv); + entry.stored_at = message.timestamp; + + if let Some(ref dir) = self.storage_path { + let path = dir.join(entry.filename()); + if let Err(e) = std::fs::write(&path, &packed) { + // In-memory insert still proceeds on disk failure. + tracing::warn!(error = %e, "failed to persist propagation message"); + } + } + + self.store.insert(entry); + true + } + + /// Store an already propagation-packed LXMF blob (`dest_hash || encrypted_data`). + /// + /// This is the normal client -> propagation-node ingress path. Unlike + /// [`Self::accept_message`], the node cannot decrypt or unpack this data; + /// it indexes by the transient ID and serves the raw blob back to the + /// destination client during `/get`. + pub fn accept_propagated_blob(&mut self, lxmf_data: &[u8], stamp_value: u8) -> bool { + if lxmf_data.len() < DESTINATION_LENGTH + 1 { + return false; + } + + let full = rns_crypto::sha::full_hash(lxmf_data); + let mut transient_id = [0u8; 16]; + transient_id.copy_from_slice(&full[..16]); + if self.store.contains(&transient_id) { + return false; + } + if self.store.total_size() > self.config.max_storage { + return false; + } + if lxmf_data.len() > self.config.max_message_size { + return false; + } + + let mut destination_hash = [0u8; 16]; + destination_hash.copy_from_slice(&lxmf_data[..DESTINATION_LENGTH]); + + let mut message_hash = [0u8; 32]; + message_hash.copy_from_slice(&full); + + let entry = PropagationEntry::new( + transient_id, + message_hash, + destination_hash, + lxmf_data.len(), + stamp_value, + ); + + if let Some(ref dir) = self.storage_path { + let path = dir.join(entry.filename()); + if let Err(e) = std::fs::write(&path, lxmf_data) { + tracing::warn!(error = %e, "failed to persist propagated message"); + } + } + + self.store.insert(entry) + } + + fn load_from_disk(&mut self) -> std::io::Result<()> { + let dir = match &self.storage_path { + Some(d) => d, + None => return Ok(()), + }; + + if !dir.exists() { + return Ok(()); + } + + let mut loaded = 0; + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let filename = match path.file_name().and_then(|f| f.to_str()) { + Some(f) => f.to_string(), + None => continue, + }; + + if filename.ends_with(".peer") || filename.ends_with(".msgpack") { + continue; + } + + if let Some((tid, ts, sv)) = PropagationEntry::parse_filename(&filename) { + let data = std::fs::read(&path)?; + let size = data.len(); + + if self.store.contains(&tid) { + continue; + } + + let mut message_hash = [0u8; 32]; + message_hash.copy_from_slice(&rns_crypto::sha::full_hash(&data)); + + // Opaque propagated blobs are stored as `dest_hash || encrypted_data` + // and cannot be unpacked by the node. Recover the routing key from + // the first 16 bytes before trying the legacy full-message path. + let mut destination_hash = [0u8; 16]; + if data.len() >= DESTINATION_LENGTH { + destination_hash.copy_from_slice(&data[..DESTINATION_LENGTH]); + } + + let mut pe = PropagationEntry::new(tid, message_hash, destination_hash, size, sv); + pe.stored_at = ts; + + if let Ok(msg) = LxMessage::unpack(&data) { + pe.message_hash = msg.hash.unwrap_or([0u8; 32]); + pe.destination_hash = msg.destination_hash; + } + + self.store.insert(pe); + loaded += 1; + } + } + + if loaded > 0 { + tracing::info!(loaded, "loaded propagation messages from disk"); + } + + Ok(()) + } + + /// Periodic maintenance: cull expired entries and clean up orphaned files. + pub fn tick(&mut self) { + let before = self.store.len(); + self.store.cull_expired(self.config.max_message_age); + let after = self.store.len(); + + if before > after + && let Some(ref dir) = self.storage_path + { + self.cleanup_orphaned_files(dir); + } + } + + fn cleanup_orphaned_files(&self, dir: &std::path::Path) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let filename = match path.file_name().and_then(|f| f.to_str()) { + Some(f) => f.to_string(), + None => continue, + }; + if filename.ends_with(".peer") || filename.ends_with(".msgpack") { + continue; + } + if let Some((tid, _, _)) = PropagationEntry::parse_filename(&filename) + && !self.store.contains(&tid) + { + let _ = std::fs::remove_file(&path); + } + } + } + } + + /// When `peer_min_stamp_cost` is `Some`, include only messages whose stamp + /// value meets the peer's threshold, so we don't send messages the peer + /// would reject for insufficient PoW. + pub fn create_offer( + &self, + _peer_hash: [u8; 16], + peer_min_stamp_cost: Option, + ) -> Vec<[u8; 16]> { + match peer_min_stamp_cost { + Some(min_cost) if min_cost > 0 => self + .store + .entries() + .filter(|e| e.stamp_value >= min_cost) + .map(|e| e.transient_id) + .collect(), + _ => self.store.transient_ids(), + } + } + + /// Returns only messages the peer has not already received. + pub fn create_offer_filtered(&self, handled: &HashSet<[u8; 16]>) -> Vec<[u8; 16]> { + self.store + .transient_ids() + .into_iter() + .filter(|id| !handled.contains(id)) + .collect() + } + + pub fn message_count(&self) -> usize { + self.store.len() + } + + pub fn total_size(&self) -> usize { + self.store.total_size() + } + + pub fn contains(&self, transient_id: &[u8; 16]) -> bool { + self.store.contains(transient_id) + } + + pub fn get_session(&self, peer_hash: &[u8; 16]) -> Option<&SyncSession> { + self.sync_sessions.get(peer_hash) + } + + pub fn get_session_mut(&mut self, peer_hash: &[u8; 16]) -> Option<&mut SyncSession> { + self.sync_sessions.get_mut(peer_hash) + } + + pub fn start_session(&mut self, peer_hash: [u8; 16]) -> &mut SyncSession { + self.sync_sessions + .entry(peer_hash) + .or_insert_with(|| SyncSession::new(peer_hash)) + } + + pub fn remove_session(&mut self, peer_hash: &[u8; 16]) { + self.sync_sessions.remove(peer_hash); + } + + pub fn save_peer(&self, peer: &LxmPeer) -> std::io::Result<()> { + if let Some(ref dir) = self.storage_path { + let filename = format!("{}.peer", hex_encode(&peer.destination_hash)); + let path = dir.join(filename); + let data = peer.to_bytes_with_handled(); + std::fs::write(path, data)?; + } + Ok(()) + } + + /// Inverse-offer pattern: the peer lists what it has; we return the IDs + /// we hold that the peer does not. Python reference: + /// LXMRouter.offer_request_received(). + pub fn offer_request( + &mut self, + _peer_hash: [u8; 16], + offered_ids: &[[u8; 16]], + ) -> Vec<[u8; 16]> { + let peer_has: HashSet<[u8; 16]> = offered_ids.iter().copied().collect(); + + self.store + .transient_ids() + .into_iter() + .filter(|id| !peer_has.contains(id)) + .collect() + } + + /// Offer request with typed error responses. Python reference: + /// LXMRouter.offer_request() (LXMRouter.py:2139-2189). + /// + /// The returned `OfferResponse` distinguishes + /// NoIdentity/Throttled/NoAccess/InvalidKey errors from + /// HaveAll/WantAll/WantSome outcomes. + pub fn offer_request_checked( + &mut self, + _peer_hash: [u8; 16], + identity_known: bool, + is_throttled: bool, + access_allowed: bool, + peering_key_valid: bool, + offered_ids: &[[u8; 16]], + ) -> OfferResponse { + if !identity_known { + return OfferResponse::ErrorNoIdentity; + } + if is_throttled { + return OfferResponse::ErrorThrottled; + } + if !access_allowed { + return OfferResponse::ErrorNoAccess; + } + if !peering_key_valid { + return OfferResponse::ErrorInvalidKey; + } + + let wanted: Vec<[u8; 16]> = offered_ids + .iter() + .filter(|id| !self.store.contains(id)) + .copied() + .collect(); + + if wanted.is_empty() { + OfferResponse::HaveAll + } else if wanted.len() == offered_ids.len() { + OfferResponse::WantAll + } else { + OfferResponse::WantSome(wanted.iter().map(|id| id.to_vec()).collect()) + } + } + + /// Wire format matches Python: Boolean for WantAll/HaveAll, integer for + /// error codes, array of binary IDs for WantSome. + pub fn encode_offer_response(response: &OfferResponse) -> Vec { + use rmpv::Value; + + let value = match response { + OfferResponse::WantAll => Value::Boolean(true), + OfferResponse::HaveAll => Value::Boolean(false), + OfferResponse::WantSome(ids) => { + Value::Array(ids.iter().map(|id| Value::Binary(id.clone())).collect()) + } + OfferResponse::ErrorNoIdentity => Value::from(PeerError::NoIdentity as u64), + OfferResponse::ErrorNoAccess => Value::from(PeerError::NoAccess as u64), + OfferResponse::ErrorInvalidKey => Value::from(PeerError::InvalidKey as u64), + OfferResponse::ErrorThrottled => Value::from(PeerError::Throttled as u64), + OfferResponse::ErrorInvalidData => Value::from(PeerError::InvalidData as u64), + OfferResponse::ErrorInvalidStamp => Value::from(PeerError::InvalidStamp as u64), + OfferResponse::Unknown => Value::Nil, + }; + + crate::encode_value(&value) + } + + /// Handle a Link REQUEST at the `/offer` path. Python reference: + /// LXMRouter.offer_request() (LXMRouter.py:2139-2189). + /// + /// `request_data` is msgpack `[peering_key, [transient_id_1, ...]]`. + /// Decodes, runs `offer_request_checked`, and returns an encoded + /// `OfferResponse` ready for `link.create_response()`. + pub fn handle_offer_request( + &mut self, + request_data: &[u8], + peer_hash: [u8; 16], + identity_known: bool, + is_throttled: bool, + access_allowed: bool, + remote_identity_hash: Option<&[u8; 16]>, + ) -> Vec { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + if let Some(&last_time) = self.last_offer_times.get(&peer_hash) + && now - last_time < PN_STAMP_THROTTLE as f64 + { + return Self::encode_offer_response(&OfferResponse::ErrorThrottled); + } + + let (peering_key, offered_ids) = match Self::decode_offer_request(request_data) { + Some(parsed) => parsed, + None => { + return Self::encode_offer_response(&OfferResponse::ErrorInvalidData); + } + }; + + // peering_id = self.dest_hash || remote_identity_hash. Empty key means + // peering-cost enforcement is disabled. + let peering_key_valid = if peering_key.is_empty() { + true + } else if peering_key.len() == 32 { + if let Some(remote_hash) = remote_identity_hash { + let mut key = [0u8; 32]; + key.copy_from_slice(&peering_key); + let mut peering_id = Vec::with_capacity(32); + peering_id.extend_from_slice(&self.dest_hash); + peering_id.extend_from_slice(remote_hash); + crate::stamper::validate_peering_key(&peering_id, &key, self.config.peering_cost) + } else { + false + } + } else { + false + }; + + let response = self.offer_request_checked( + peer_hash, + identity_known, + is_throttled, + access_allowed, + peering_key_valid, + &offered_ids, + ); + + self.last_offer_times.insert(peer_hash, now); + + Self::encode_offer_response(&response) + } + + /// Expected wire format: `[peering_key_bytes, [transient_id_1, ...]]`. + fn decode_offer_request(data: &[u8]) -> Option<(Vec, Vec<[u8; 16]>)> { + let value: rmpv::Value = rmpv::decode::read_value(&mut &data[..]).ok()?; + let arr = value.as_array()?; + if arr.len() < 2 { + return None; + } + + let peering_key = arr[0].as_slice().unwrap_or(&[]).to_vec(); + + let ids_array = arr[1].as_array()?; + let mut offered_ids = Vec::with_capacity(ids_array.len()); + for id_val in ids_array { + if let Some(id_bytes) = id_val.as_slice() { + match id_bytes.len() { + 16 => { + let mut tid = [0u8; 16]; + tid.copy_from_slice(id_bytes); + offered_ids.push(tid); + } + 32 => { + let mut tid = [0u8; 16]; + tid.copy_from_slice(&id_bytes[..16]); + offered_ids.push(tid); + } + _ => {} + } + } + } + + Some((peering_key, offered_ids)) + } + + /// Handle a Link REQUEST at the `/get` path for client download. Python + /// reference: LXMRouter.message_get_request() (LXMRouter.py:484-587). + /// + /// Wire format is msgpack `[wants, haves]` or `[wants, haves, delivery_limit]`: + /// - Phase 1 (list): `[None, None]` -> available transient IDs for the client. + /// - Phase 2 (get): `[[wants...], [haves...]]` -> message payloads; haves + /// are purged in the same call. + /// - Phase 3 (purge): `[None, [received_ids...]]` -> delete from store. + pub fn handle_get_request( + &mut self, + request_data: &[u8], + client_dest_hash: &[u8; 16], + ) -> Vec { + use rmpv::Value; + + let value: rmpv::Value = match rmpv::decode::read_value(&mut &request_data[..]) { + Ok(v) => v, + Err(_) => { + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &Value::Nil).ok(); + return buf; + } + }; + + let arr = match value.as_array() { + Some(a) if a.len() >= 2 => a, + _ => { + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &Value::Nil).ok(); + return buf; + } + }; + + let wants_is_nil = arr[0].is_nil(); + let haves_is_nil = arr[1].is_nil(); + + fn parse_store_id(value: &rmpv::Value) -> Option<[u8; 16]> { + let id_bytes = value.as_slice()?; + match id_bytes.len() { + 16 => { + let mut tid = [0u8; 16]; + tid.copy_from_slice(id_bytes); + Some(tid) + } + 32 => { + let mut tid = [0u8; 16]; + tid.copy_from_slice(&id_bytes[..16]); + Some(tid) + } + _ => None, + } + } + + if wants_is_nil && haves_is_nil { + // Phase 1: list available messages for this client. + let available = self.store.entries_for_destination(client_dest_hash); + let id_list: Vec = available + .iter() + .map(|e| Value::Binary(e.transient_id.to_vec())) + .collect(); + let response = Value::Array(id_list); + crate::encode_value(&response) + } else if wants_is_nil && !haves_is_nil { + // Phase 3: purge messages the client already received. + if let Some(haves_arr) = arr[1].as_array() { + for have_val in haves_arr { + if let Some(tid) = parse_store_id(have_val) + && let Some(entry) = self.store.remove(&tid) + && let Some(ref dir) = self.storage_path + { + let path = dir.join(entry.filename()); + let _ = std::fs::remove_file(&path); + } + } + } + crate::encode_value(&Value::Boolean(true)) + } else { + // Phase 2: return requested message data. + let mut messages: Vec = Vec::new(); + + if let Some(wants_arr) = arr[0].as_array() { + let delivery_limit = if arr.len() > 2 { arr[2].as_f64() } else { None }; + let limit_bytes = delivery_limit + .map(|kb| (kb * 1024.0) as usize) + .unwrap_or(usize::MAX); + let mut total_sent = 0usize; + + for want_val in wants_arr { + if let Some(tid) = parse_store_id(want_val) + && let Some(ref dir) = self.storage_path + && let Some(entry) = self.store.get(&tid) + { + let path = dir.join(entry.filename()); + if let Ok(data) = std::fs::read(&path) { + if total_sent + data.len() > limit_bytes { + break; + } + total_sent += data.len(); + messages.push(Value::Binary(data)); + } + } + } + } + + // Purge haves in the same call. + if let Some(haves_arr) = arr[1].as_array() { + for have_val in haves_arr { + if let Some(tid) = parse_store_id(have_val) + && let Some(entry) = self.store.remove(&tid) + && let Some(ref dir) = self.storage_path + { + let path = dir.join(entry.filename()); + let _ = std::fs::remove_file(&path); + } + } + } + + let response = Value::Array(messages); + crate::encode_value(&response) + } + } + + /// Fetch raw packed message data for each requested transient ID. Python + /// reference: LXMRouter.message_get_request_received(). Returns an empty + /// vec when there is no disk storage configured. + pub fn message_get_request(&self, requested_ids: &[[u8; 16]]) -> Vec<([u8; 16], Vec)> { + let dir = match &self.storage_path { + Some(d) => d, + None => return Vec::new(), + }; + + let mut results = Vec::new(); + for tid in requested_ids { + if let Some(entry) = self.store.get(tid) { + let path = dir.join(entry.filename()); + if let Ok(data) = std::fs::read(&path) { + results.push((*tid, data)); + } + } + } + results + } + + /// Produce a `SyncOffer` listing message IDs the peer has not yet handled. + /// The caller sends it over an established link. Python reference: + /// LXMRouter.sync_request_received(). + pub fn prepare_sync_offer(&mut self, peer_hash: [u8; 16]) -> SyncOffer { + // Compute IDs before borrowing sync_sessions mutably. + let our_ids = if let Some(peer) = self.load_peer(&peer_hash) { + self.create_offer_filtered(&peer.handled_messages) + } else { + self.create_offer(peer_hash, None) + }; + + let session = self + .sync_sessions + .entry(peer_hash) + .or_insert_with(|| SyncSession::new(peer_hash)); + session.prepare_offer(our_ids, Vec::new()) + } + + /// Compare a peer's `SyncOffer` against our store and return a `SyncGet` + /// listing IDs we want. Python reference: LXMRouter.offer_request_received(). + pub fn process_sync_offer(&mut self, peer_hash: [u8; 16], offer: &SyncOffer) -> SyncGet { + // process_offer needs &self.store; compute the get before mutating sync_sessions. + let mut tmp_session = SyncSession::new(peer_hash); + let result = tmp_session.process_offer(offer, &self.store); + self.sync_sessions.insert(peer_hash, tmp_session); + result + } + + /// Return the packed message data for each ID in `get`. The caller + /// transfers each blob as a Resource over the link. Python reference: + /// LXMRouter.message_get_request_received(). + pub fn process_sync_get(&mut self, peer_hash: [u8; 16], get: &SyncGet) -> Vec> { + if let Some(session) = self.sync_sessions.get_mut(&peer_hash) { + session.process_get(get); + } else { + let mut session = SyncSession::new(peer_hash); + session.process_get(get); + self.sync_sessions.insert(peer_hash, session); + } + + let mut messages = Vec::new(); + for wanted_id_bytes in &get.wanted_ids { + if wanted_id_bytes.len() != 16 { + continue; + } + let mut tid = [0u8; 16]; + tid.copy_from_slice(wanted_id_bytes); + + if let Some(ref dir) = self.storage_path + && let Some(entry) = self.store.get(&tid) + { + let path = dir.join(entry.filename()); + if let Ok(data) = std::fs::read(&path) { + messages.push(data); + } + } + } + + messages + } + + /// Record a successful transfer for a peer. Loads the peer, adds the + /// transient ID to its handled set, saves it, and records the transfer in + /// the sync session. Python reference: + /// LXMRouter.propagation_resource_concluded() (LXMRouter.py:2271) -- + /// `peer.queue_handled_message(transient_id)`. + pub fn mark_peer_handled(&mut self, peer_hash: &[u8; 16], transient_id: &[u8; 16]) { + if let Some(mut peer) = self.load_peer(peer_hash) { + peer.add_handled_message(transient_id); + let _ = self.save_peer(&peer); + } + + if let Some(session) = self.sync_sessions.get_mut(peer_hash) { + session.record_transfer(); + } + } + + pub fn complete_sync(&mut self, peer_hash: &[u8; 16]) { + if let Some(session) = self.sync_sessions.get_mut(peer_hash) { + session.mark_complete(); + } + self.remove_session(peer_hash); + } + + fn load_peer(&self, peer_hash: &[u8; 16]) -> Option { + let dir = self.storage_path.as_ref()?; + let filename = format!("{}.peer", hex_encode(peer_hash)); + let path = dir.join(filename); + let data = std::fs::read(&path).ok()?; + LxmPeer::from_bytes_with_handled(&data) + } + + pub fn load_peers(&self) -> Vec { + let dir = match &self.storage_path { + Some(d) => d, + None => return Vec::new(), + }; + + let mut peers = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "peer").unwrap_or(false) + && let Ok(data) = std::fs::read(&path) + && let Some(peer) = LxmPeer::from_bytes_with_handled(&data) + { + peers.push(peer); + } + } + } + peers + } +} + +impl std::fmt::Debug for PropagationNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PropagationNode") + .field("dest_hash", &hex_encode(&self.dest_hash)) + .field("message_count", &self.store.len()) + .field("total_size", &self.store.total_size()) + .field("sessions", &self.sync_sessions.len()) + .field("storage_path", &self.storage_path) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::DeliveryMethod; + + fn make_signed_message(dest: [u8; 16], src: [u8; 16], title: &str, content: &str) -> LxMessage { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new(dest, src, title, content, DeliveryMethod::Propagated); + msg.sign(&key).unwrap(); + msg + } + + #[test] + fn test_new_propagation_node() { + let node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + assert_eq!(node.message_count(), 0); + assert_eq!(node.total_size(), 0); + assert_eq!(node.dest_hash, [0xAA; 16]); + } + + #[test] + fn test_accept_message() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "content"); + assert!(msg.hash.is_some()); + assert!(node.accept_message(&msg)); + assert_eq!(node.message_count(), 1); + } + + #[test] + fn test_reject_duplicate() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "duplicate"); + assert!(node.accept_message(&msg)); + assert!(!node.accept_message(&msg)); + assert_eq!(node.message_count(), 1); + } + + #[test] + fn test_reject_no_hash() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg = LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "no hash", + DeliveryMethod::Propagated, + ); + assert!(msg.hash.is_none()); + assert!(!node.accept_message(&msg)); + } + + #[test] + fn test_reject_store_full() { + let config = PropagationNodeConfig { + max_storage: 1, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg1"); + assert!(node.accept_message(&msg1)); + + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg2"); + assert!(!node.accept_message(&msg2)); + } + + #[test] + fn test_create_offer() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg1"); + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg2"); + node.accept_message(&msg1); + node.accept_message(&msg2); + + let offer = node.create_offer([0xFF; 16], None); + assert_eq!(offer.len(), 2); + } + + #[test] + fn test_create_offer_filtered() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg1"); + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg2"); + + let tid1 = rns_crypto::sha::truncated_hash(&msg1.hash.unwrap()); + node.accept_message(&msg1); + node.accept_message(&msg2); + + let all = node.create_offer([0xFF; 16], None); + assert_eq!(all.len(), 2); + + let mut handled = HashSet::new(); + handled.insert(tid1); + + let filtered = node.create_offer_filtered(&handled); + assert_eq!(filtered.len(), 1); + } + + #[test] + fn test_propagation_disk_persistence() { + let dir = std::env::temp_dir().join("lxmf_test_prop_persist"); + let _ = std::fs::remove_dir_all(&dir); + + { + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "persistent content"); + assert!(node.accept_message(&msg)); + assert_eq!(node.message_count(), 1); + } + + // Fresh node reloads from disk. + { + let node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + assert_eq!(node.message_count(), 1); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_tick_culls_expired() { + let config = PropagationNodeConfig { + max_message_age: 1, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + + let mut msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "will expire"); + msg.timestamp = 1000.0; + node.accept_message(&msg); + assert_eq!(node.message_count(), 1); + + node.tick(); + assert_eq!(node.message_count(), 0); + } + + /// After a message is culled (expired), the same message resurfacing + /// must be accepted again — the node's "seen" memory is the store + /// itself, not a separate dedup log. Otherwise a node that culled a + /// message and then received it again from another peer would + /// silently drop it, breaking store-and-forward semantics. + #[test] + fn test_reaccept_after_cull() { + let config = PropagationNodeConfig { + max_message_age: 1, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + + let mut msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "cull then redeliver"); + msg.timestamp = 1000.0; + assert!(node.accept_message(&msg), "first accept"); + assert_eq!(node.message_count(), 1); + + node.tick(); + assert_eq!(node.message_count(), 0, "culled by tick"); + + // Fresh timestamp so the re-delivery isn't itself expired. + msg.timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64(); + assert!( + node.accept_message(&msg), + "same message re-accepted after cull" + ); + assert_eq!(node.message_count(), 1); + } + + /// A store that was full and rejecting new messages must recover + /// capacity after culling — the reject-store-full path is transient, + /// not terminal. Exercises: fill → reject → cull expired → accept. + #[test] + fn test_accept_after_store_full_and_cull() { + let config = PropagationNodeConfig { + max_storage: 1, + max_message_age: 1, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + + let mut msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "first"); + msg1.timestamp = 1000.0; // ancient so tick will cull it + assert!(node.accept_message(&msg1)); + + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "rejected-while-full"); + assert!( + !node.accept_message(&msg2), + "store full, second message must reject" + ); + + node.tick(); + assert_eq!(node.message_count(), 0, "expired msg culled"); + + let msg3 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "accepted-after-cull"); + assert!( + node.accept_message(&msg3), + "store has space after cull, next message accepted" + ); + assert_eq!(node.message_count(), 1); + } + + #[test] + fn test_peer_persistence() { + let dir = std::env::temp_dir().join("lxmf_test_peer_persist"); + let _ = std::fs::remove_dir_all(&dir); + + let node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let mut peer = LxmPeer::new([0xBB; 16]); + peer.add_handled_message(&[0xCC; 16]); + node.save_peer(&peer).unwrap(); + + let loaded_peers = node.load_peers(); + assert_eq!(loaded_peers.len(), 1); + assert!(loaded_peers[0].has_handled(&[0xCC; 16])); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_peer_persistence_multiple() { + let dir = std::env::temp_dir().join("lxmf_test_peer_persist_multi"); + let _ = std::fs::remove_dir_all(&dir); + + let node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let mut peer1 = LxmPeer::new([0xBB; 16]); + peer1.add_handled_message(&[0x11; 16]); + node.save_peer(&peer1).unwrap(); + + let mut peer2 = LxmPeer::new([0xDD; 16]); + peer2.add_handled_message(&[0x22; 16]); + peer2.add_handled_message(&[0x33; 16]); + node.save_peer(&peer2).unwrap(); + + let loaded = node.load_peers(); + assert_eq!(loaded.len(), 2); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_no_persistence_without_storage_path() { + let node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let peer = LxmPeer::new([0xBB; 16]); + node.save_peer(&peer).unwrap(); + + let loaded = node.load_peers(); + assert!(loaded.is_empty()); + } + + #[test] + fn test_disk_cleanup_on_cull() { + let dir = std::env::temp_dir().join("lxmf_test_disk_cleanup"); + let _ = std::fs::remove_dir_all(&dir); + + let config = PropagationNodeConfig { + max_message_age: 1, + ..Default::default() + }; + let mut node = PropagationNode::with_storage(config, [0xAA; 16], dir.clone()).unwrap(); + + let mut msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "cleanup test"); + msg.timestamp = 1000.0; + node.accept_message(&msg); + + let file_count = std::fs::read_dir(&dir) + .unwrap() + .filter(|e| { + e.as_ref() + .ok() + .and_then(|e| { + e.path() + .file_name() + .map(|f| !f.to_str().unwrap_or("").ends_with(".peer")) + }) + .unwrap_or(false) + }) + .count(); + assert_eq!(file_count, 1); + + node.tick(); + assert_eq!(node.message_count(), 0); + + let remaining = std::fs::read_dir(&dir) + .unwrap() + .filter(|e| { + e.as_ref() + .ok() + .and_then(|e| { + e.path() + .file_name() + .map(|f| !f.to_str().unwrap_or("").ends_with(".peer")) + }) + .unwrap_or(false) + }) + .count(); + assert_eq!(remaining, 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_sync_session_management() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let peer_hash = [0xBB; 16]; + + assert!(node.get_session(&peer_hash).is_none()); + + let session = node.start_session(peer_hash); + assert_eq!(session.peer_hash, peer_hash); + + assert!(node.get_session(&peer_hash).is_some()); + + node.remove_session(&peer_hash); + assert!(node.get_session(&peer_hash).is_none()); + } + + #[test] + fn test_offer_request_returns_missing() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg1"); + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg2"); + let msg3 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "msg3"); + + let tid1 = rns_crypto::sha::truncated_hash(&msg1.hash.unwrap()); + let tid2 = rns_crypto::sha::truncated_hash(&msg2.hash.unwrap()); + let tid3 = rns_crypto::sha::truncated_hash(&msg3.hash.unwrap()); + + node.accept_message(&msg1); + node.accept_message(&msg2); + node.accept_message(&msg3); + + let peer_has = [tid1, tid2]; + let missing = node.offer_request([0xDD; 16], &peer_has); + + assert_eq!(missing.len(), 1); + assert!(missing.contains(&tid3)); + } + + #[test] + fn test_offer_request_peer_has_nothing() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "content"); + node.accept_message(&msg); + + let missing = node.offer_request([0xDD; 16], &[]); + assert_eq!(missing.len(), 1); + } + + #[test] + fn test_offer_request_peer_has_everything() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + let missing = node.offer_request([0xDD; 16], &[tid]); + assert!(missing.is_empty()); + } + + #[test] + fn test_message_get_request_with_storage() { + let dir = std::env::temp_dir().join("lxmf_test_msg_get"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "get request content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + let results = node.message_get_request(&[tid]); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, tid); + assert!(!results[0].1.is_empty()); + + let unpacked = LxMessage::unpack(&results[0].1); + assert!(unpacked.is_ok()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_message_get_request_unknown_id() { + let dir = std::env::temp_dir().join("lxmf_test_msg_get_unknown"); + let _ = std::fs::remove_dir_all(&dir); + + let node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let results = node.message_get_request(&[[0xFF; 16]]); + assert!(results.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_message_get_request_no_storage() { + let node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let results = node.message_get_request(&[[0xFF; 16]]); + assert!(results.is_empty()); + } + + #[test] + fn test_prepare_sync_offer() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "sync1"); + let msg2 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "sync2"); + node.accept_message(&msg1); + node.accept_message(&msg2); + + let peer_hash = [0xDD; 16]; + let offer = node.prepare_sync_offer(peer_hash); + + assert_eq!(offer.transient_ids.len(), 2); + assert!(node.get_session(&peer_hash).is_some()); + } + + #[test] + fn test_process_sync_offer_and_get() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let msg1 = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "has_this"); + let tid1 = rns_crypto::sha::truncated_hash(&msg1.hash.unwrap()); + node.accept_message(&msg1); + + let peer_hash = [0xDD; 16]; + let tid2 = [0xEE; 16]; + let offer = crate::sync::SyncOffer { + peering_key: Vec::new(), + transient_ids: vec![tid1.to_vec(), tid2.to_vec()], + }; + + let get = node.process_sync_offer(peer_hash, &offer); + assert_eq!(get.wanted_ids.len(), 1); + assert_eq!(get.wanted_ids[0], tid2.to_vec()); + } + + #[test] + fn test_sync_lifecycle_complete() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let peer_hash = [0xDD; 16]; + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "lifecycle"); + node.accept_message(&msg); + + let _offer = node.prepare_sync_offer(peer_hash); + assert!(node.get_session(&peer_hash).is_some()); + + node.complete_sync(&peer_hash); + assert!(node.get_session(&peer_hash).is_none()); + } + + #[test] + fn test_process_sync_get_with_storage() { + let dir = std::env::temp_dir().join("lxmf_test_sync_get"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "sync get content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + let get = crate::sync::SyncGet { + wanted_ids: vec![tid.to_vec()], + }; + let peer_hash = [0xDD; 16]; + let messages = node.process_sync_get(peer_hash, &get); + + assert_eq!(messages.len(), 1); + assert!(!messages[0].is_empty()); + + let unpacked = LxMessage::unpack(&messages[0]); + assert!(unpacked.is_ok()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_offer_request_checked_no_identity() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let resp = node.offer_request_checked([0xDD; 16], false, false, true, true, &[]); + assert_eq!(resp, OfferResponse::ErrorNoIdentity); + } + + #[test] + fn test_offer_request_checked_throttled() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let resp = node.offer_request_checked([0xDD; 16], true, true, true, true, &[]); + assert_eq!(resp, OfferResponse::ErrorThrottled); + } + + #[test] + fn test_offer_request_checked_no_access() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let resp = node.offer_request_checked([0xDD; 16], true, false, false, true, &[]); + assert_eq!(resp, OfferResponse::ErrorNoAccess); + } + + #[test] + fn test_offer_request_checked_invalid_key() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let resp = node.offer_request_checked([0xDD; 16], true, false, true, false, &[]); + assert_eq!(resp, OfferResponse::ErrorInvalidKey); + } + + #[test] + fn test_offer_request_checked_have_all() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + let resp = node.offer_request_checked([0xDD; 16], true, false, true, true, &[tid]); + assert_eq!(resp, OfferResponse::HaveAll); + } + + #[test] + fn test_offer_request_checked_want_all() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let resp = node.offer_request_checked( + [0xDD; 16], + true, + false, + true, + true, + &[[0x11; 16], [0x22; 16]], + ); + assert_eq!(resp, OfferResponse::WantAll); + } + + #[test] + fn test_offer_request_checked_want_some() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + let resp = + node.offer_request_checked([0xDD; 16], true, false, true, true, &[tid, [0x99; 16]]); + match resp { + OfferResponse::WantSome(ids) => { + assert_eq!(ids.len(), 1); + assert_eq!(ids[0], [0x99; 16].to_vec()); + } + _ => panic!("expected WantSome"), + } + } + + #[test] + fn test_encode_offer_response_roundtrip() { + let encoded = PropagationNode::encode_offer_response(&OfferResponse::WantAll); + let parsed = OfferResponse::from_msgpack(&encoded); + assert_eq!(parsed, OfferResponse::WantAll); + + let encoded = PropagationNode::encode_offer_response(&OfferResponse::HaveAll); + let parsed = OfferResponse::from_msgpack(&encoded); + assert_eq!(parsed, OfferResponse::HaveAll); + + let encoded = PropagationNode::encode_offer_response(&OfferResponse::ErrorNoIdentity); + let parsed = OfferResponse::from_msgpack(&encoded); + assert_eq!(parsed, OfferResponse::ErrorNoIdentity); + + let encoded = PropagationNode::encode_offer_response(&OfferResponse::ErrorThrottled); + let parsed = OfferResponse::from_msgpack(&encoded); + assert_eq!(parsed, OfferResponse::ErrorThrottled); + + let ids = vec![vec![0xAA; 16], vec![0xBB; 16]]; + let encoded = PropagationNode::encode_offer_response(&OfferResponse::WantSome(ids.clone())); + let parsed = OfferResponse::from_msgpack(&encoded); + match parsed { + OfferResponse::WantSome(parsed_ids) => { + assert_eq!(parsed_ids, ids); + } + _ => panic!("expected WantSome"), + } + } + + #[test] + fn test_handle_offer_request_valid() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + // Empty peering key disables peering-cost enforcement. + use rmpv::Value; + let offer = Value::Array(vec![ + Value::Binary(vec![]), + Value::Array(vec![ + Value::Binary(vec![0x11; 16]), + Value::Binary(vec![0x22; 16]), + ]), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &offer).unwrap(); + + let response_bytes = node.handle_offer_request(&buf, [0xBB; 16], true, false, true, None); + let response = OfferResponse::from_msgpack(&response_bytes); + assert_eq!(response, OfferResponse::WantAll); + } + + #[test] + fn test_handle_offer_request_no_identity() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + use rmpv::Value; + let offer = Value::Array(vec![Value::Binary(vec![]), Value::Array(vec![])]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &offer).unwrap(); + + let response_bytes = node.handle_offer_request(&buf, [0xBB; 16], false, false, true, None); + let response = OfferResponse::from_msgpack(&response_bytes); + assert_eq!(response, OfferResponse::ErrorNoIdentity); + } + + #[test] + fn test_handle_offer_request_invalid_data() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let response_bytes = + node.handle_offer_request(&[0xFF, 0xFF], [0xBB; 16], true, false, true, None); + let response = OfferResponse::from_msgpack(&response_bytes); + assert_eq!(response, OfferResponse::ErrorInvalidData); + } + + #[test] + fn test_decode_offer_request_valid() { + use rmpv::Value; + let offer = Value::Array(vec![ + Value::Binary(vec![0xAA; 32]), + Value::Array(vec![ + Value::Binary(vec![0x11; 16]), + Value::Binary(vec![0x22; 16]), + ]), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &offer).unwrap(); + + let result = PropagationNode::decode_offer_request(&buf); + assert!(result.is_some()); + let (key, ids) = result.unwrap(); + assert_eq!(key, vec![0xAA; 32]); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], [0x11; 16]); + assert_eq!(ids[1], [0x22; 16]); + } + + #[test] + fn test_decode_offer_request_filters_bad_ids() { + use rmpv::Value; + let offer = Value::Array(vec![ + Value::Binary(vec![]), + Value::Array(vec![ + Value::Binary(vec![0x11; 16]), + Value::Binary(vec![0x22; 8]), + Value::Binary(vec![0x33; 32]), + ]), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &offer).unwrap(); + + let (_, ids) = PropagationNode::decode_offer_request(&buf).unwrap(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], [0x11; 16]); + assert_eq!(ids[1], [0x33; 16]); + } + + #[test] + fn test_handle_get_request_list_phase() { + let dir = std::env::temp_dir().join("lxmf_test_get_list"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "get list content"); + node.accept_message(&msg); + + use rmpv::Value; + let request = Value::Array(vec![Value::Nil, Value::Nil]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &request).unwrap(); + + let response_bytes = node.handle_get_request(&buf, &[0xBB; 16]); + let response: rmpv::Value = rmpv::decode::read_value(&mut &response_bytes[..]).unwrap(); + let arr = response.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0].as_slice().unwrap().len(), 16); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_handle_get_request_list_empty() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + use rmpv::Value; + let request = Value::Array(vec![Value::Nil, Value::Nil]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &request).unwrap(); + + let response_bytes = node.handle_get_request(&buf, &[0xBB; 16]); + let response: rmpv::Value = rmpv::decode::read_value(&mut &response_bytes[..]).unwrap(); + let arr = response.as_array().unwrap(); + assert!(arr.is_empty()); + } + + #[test] + fn test_accept_propagated_blob_and_get_with_full_hash_id() { + let dir = std::env::temp_dir().join("lxmf_test_propagated_blob_get"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let mut lxmf_data = vec![0xBB; 16]; + lxmf_data.extend_from_slice(&[0xCC; 128]); + assert!(node.accept_propagated_blob(&lxmf_data, 0)); + + let full_id = rns_crypto::sha::full_hash(&lxmf_data); + use rmpv::Value; + let list_request = Value::Array(vec![Value::Nil, Value::Nil]); + let mut list_buf = Vec::new(); + rmpv::encode::write_value(&mut list_buf, &list_request).unwrap(); + let list_response = node.handle_get_request(&list_buf, &[0xBB; 16]); + let list_value: Value = rmpv::decode::read_value(&mut &list_response[..]).unwrap(); + assert_eq!(list_value.as_array().unwrap().len(), 1); + + let get_request = Value::Array(vec![ + Value::Array(vec![Value::Binary(full_id.to_vec())]), + Value::Array(vec![]), + ]); + let mut get_buf = Vec::new(); + rmpv::encode::write_value(&mut get_buf, &get_request).unwrap(); + let get_response = node.handle_get_request(&get_buf, &[0xBB; 16]); + let get_value: Value = rmpv::decode::read_value(&mut &get_response[..]).unwrap(); + let messages = get_value.as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].as_slice().unwrap(), lxmf_data.as_slice()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_opaque_propagated_blob_reload_preserves_destination() { + let dir = std::env::temp_dir().join("lxmf_test_propagated_blob_reload"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let mut lxmf_data = vec![0xBB; 16]; + lxmf_data.extend_from_slice(&[0xCC; 128]); + assert!(node.accept_propagated_blob(&lxmf_data, 0)); + drop(node); + + let mut reloaded = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + use rmpv::Value; + let list_request = Value::Array(vec![Value::Nil, Value::Nil]); + let mut list_buf = Vec::new(); + rmpv::encode::write_value(&mut list_buf, &list_request).unwrap(); + let response = reloaded.handle_get_request(&list_buf, &[0xBB; 16]); + let value: Value = rmpv::decode::read_value(&mut &response[..]).unwrap(); + assert_eq!(value.as_array().unwrap().len(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_handle_get_request_purge_phase() { + let dir = std::env::temp_dir().join("lxmf_test_get_purge"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "purge content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + assert_eq!(node.message_count(), 1); + + use rmpv::Value; + let request = Value::Array(vec![ + Value::Nil, + Value::Array(vec![Value::Binary(tid.to_vec())]), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &request).unwrap(); + + let _response_bytes = node.handle_get_request(&buf, &[0xBB; 16]); + assert_eq!(node.message_count(), 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_handle_get_request_get_phase() { + let dir = std::env::temp_dir().join("lxmf_test_get_data"); + let _ = std::fs::remove_dir_all(&dir); + + let mut node = PropagationNode::with_storage( + PropagationNodeConfig::default(), + [0xAA; 16], + dir.clone(), + ) + .unwrap(); + + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "get data content"); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + node.accept_message(&msg); + + use rmpv::Value; + let request = Value::Array(vec![ + Value::Array(vec![Value::Binary(tid.to_vec())]), + Value::Array(vec![]), + ]); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &request).unwrap(); + + let response_bytes = node.handle_get_request(&buf, &[0xBB; 16]); + let response: rmpv::Value = rmpv::decode::read_value(&mut &response_bytes[..]).unwrap(); + let arr = response.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert!(!arr[0].as_slice().unwrap().is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_stamp_cost_validation_rejects_unstamped() { + let config = PropagationNodeConfig { + min_stamp_cost: 8, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "unstamped"); + + assert!(!node.accept_message(&msg)); + assert_eq!(node.message_count(), 0); + } + + #[test] + fn test_stamp_cost_zero_accepts_all() { + let config = PropagationNodeConfig { + min_stamp_cost: 0, + ..Default::default() + }; + let mut node = PropagationNode::new(config, [0xAA; 16]); + let msg = make_signed_message([0xBB; 16], [0xCC; 16], "Test", "no_cost"); + + assert!(node.accept_message(&msg)); + assert_eq!(node.message_count(), 1); + } + + #[test] + fn test_create_offer_with_stamp_filter() { + let mut node = PropagationNode::new(PropagationNodeConfig::default(), [0xAA; 16]); + + let entry1 = crate::propagation::PropagationEntry { + transient_id: [0x01; 16], + message_hash: [0x11; 32], + destination_hash: [0xCC; 16], + stored_at: 1000.0, + stamp_value: 20, + size: 100, + collected: false, + }; + let entry2 = crate::propagation::PropagationEntry { + transient_id: [0x02; 16], + message_hash: [0x22; 32], + destination_hash: [0xCC; 16], + stored_at: 1000.0, + stamp_value: 5, + size: 100, + collected: false, + }; + node.store.insert(entry1); + node.store.insert(entry2); + + let all = node.create_offer([0xFF; 16], None); + assert_eq!(all.len(), 2); + + let filtered = node.create_offer([0xFF; 16], Some(10)); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0], [0x01; 16]); + + let all2 = node.create_offer([0xFF; 16], Some(0)); + assert_eq!(all2.len(), 2); + } +} diff --git a/crates/lxmf-core/src/propagation_sync.rs b/crates/lxmf-core/src/propagation_sync.rs new file mode 100644 index 0000000..f0f1006 --- /dev/null +++ b/crates/lxmf-core/src/propagation_sync.rs @@ -0,0 +1,1065 @@ +//! Propagation sync background task. +//! +//! Outbound sync to a configured propagation node using the Link +//! REQUEST/RESPONSE pattern. Python reference: LXMPeer.py:381-386. +//! +//! Flow: +//! 1. Establish a link to the node. +//! 2. Send link.request("/offer", [peering_key, transient_ids]). +//! 3. Receive a Response packet (context 0x0A) with OfferResponse. +//! 4. Transfer requested messages as a Resource. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use rns_crypto::ed25519::Ed25519PublicKey; +use rns_link::link::{CloseReason, Link}; +use rns_protocol::resource::{OutboundTransfer, TransferAction}; +use rns_transport::link_messages::DestinationEvent; +use rns_transport::messages::{OutboundRequest, TransportMessage}; +use tokio::sync::mpsc; + +use crate::constants::OFFER_REQUEST_PATH; +use crate::peer::LxmPeer; +use crate::propagation::hex_encode; +use crate::propagation_node::{PropagationNode, PropagationNodeConfig}; +use crate::sync::OfferResponse; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncTaskState { + Idle, + Establishing, + Offering, + AwaitingResponse, + Transferring, + Complete, + Failed, +} + +pub struct PropagationSyncTask { + transport_tx: mpsc::Sender, + event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + node_dest_hash: Option<[u8; 16]>, + pub propagation_node: PropagationNode, + link: Option, + link_id: Option<[u8; 16]>, + pub state: SyncTaskState, + last_sync: Instant, + sync_interval: Duration, + sync_started: Option, + sync_timeout: Duration, + transfer_queue: Vec>, + active_transfer: Option, + peer: Option, +} + +impl PropagationSyncTask { + pub fn new(transport_tx: mpsc::Sender, dest_hash: [u8; 16]) -> Self { + let (event_tx, event_rx) = mpsc::channel(256); + Self { + transport_tx, + event_tx, + event_rx, + node_dest_hash: None, + propagation_node: PropagationNode::new(PropagationNodeConfig::default(), dest_hash), + link: None, + link_id: None, + state: SyncTaskState::Idle, + last_sync: Instant::now(), + sync_interval: Duration::from_secs(300), + sync_started: None, + sync_timeout: Duration::from_secs(120), + transfer_queue: Vec::new(), + active_transfer: None, + peer: None, + } + } + + /// Create a sync task with disk-backed propagation storage. + pub fn with_storage( + transport_tx: mpsc::Sender, + dest_hash: [u8; 16], + storage_path: std::path::PathBuf, + ) -> std::io::Result { + let (event_tx, event_rx) = mpsc::channel(256); + Ok(Self { + transport_tx, + event_tx, + event_rx, + node_dest_hash: None, + propagation_node: PropagationNode::with_storage( + PropagationNodeConfig::default(), + dest_hash, + storage_path, + )?, + link: None, + link_id: None, + state: SyncTaskState::Idle, + last_sync: Instant::now(), + sync_interval: Duration::from_secs(300), + sync_started: None, + sync_timeout: Duration::from_secs(120), + transfer_queue: Vec::new(), + active_transfer: None, + peer: None, + }) + } + + pub fn set_node(&mut self, dest_hash: [u8; 16]) { + self.node_dest_hash = Some(dest_hash); + } + + /// Force an immediate sync attempt with `dest_hash`. + /// + /// Python `LXMPeer.sync()` is called directly by lxmd control requests; + /// this public shim preserves that behavior without waiting for the + /// periodic sync interval. + pub fn request_sync_now(&mut self, dest_hash: [u8; 16]) { + self.node_dest_hash = Some(dest_hash); + if self.state == SyncTaskState::Idle { + self.start_sync(dest_hash); + self.last_sync = Instant::now(); + } + } + + pub fn node_dest_hash(&self) -> Option<[u8; 16]> { + self.node_dest_hash + } + + pub fn accept_message(&mut self, msg: &crate::message::LxMessage) -> bool { + self.propagation_node.accept_message(msg) + } + + /// Drain inbound events from transport. + /// + /// `known_identities` maps dest_hash_hex -> 64-byte public key, used for link proof validation. + pub fn drain_events(&mut self, known_identities: &HashMap) { + let mut events = Vec::new(); + while let Ok(event) = self.event_rx.try_recv() { + events.push(event); + } + + for event in events { + match event { + DestinationEvent::LinkClosed { link_id } => { + self.handle_link_closed(link_id, None); + } + DestinationEvent::InboundPacket { raw, .. } => { + let (header, data_offset) = match rns_wire::header::PacketHeader::unpack(&raw) { + Ok(h) => h, + Err(_) => continue, + }; + if self.link_id != Some(header.destination_hash) { + continue; + } + let data = if raw.len() > data_offset { + &raw[data_offset..] + } else { + &[] + }; + + match header.context { + rns_wire::context::PacketContext::Lrproof + | rns_wire::context::PacketContext::None + if header.flags.packet_type == rns_wire::flags::PacketType::Proof + || header.context == rns_wire::context::PacketContext::Lrproof => + { + if self.state != SyncTaskState::Establishing { + continue; + } + let node_hex = self.node_dest_hash.map(|h| hex_encode(&h)); + if let Some(node_hex) = node_hex + && let Some(pub_key) = known_identities.get(&node_hex) + { + let ed25519_bytes: [u8; 32] = pub_key[32..64].try_into().unwrap(); + if let Ok(verify_key) = Ed25519PublicKey::from_bytes(&ed25519_bytes) + { + self.handle_link_proof(data, &verify_key, &ed25519_bytes); + } + } + } + rns_wire::context::PacketContext::ResourceHmu => { + if let Some(ref link) = self.link + && let Ok(plaintext) = link.decrypt(data) + && let Some(ref mut transfer) = self.active_transfer + { + transfer.handle_hmu(&plaintext); + } + } + rns_wire::context::PacketContext::ResourcePrf => { + // Python Packet.pack() sends PROOF+RESOURCE_PRF as + // plaintext (Packet.py:195-197) on PacketType::Proof. + // Body = resource_hash(32) || proof(32). + if let Some(ref mut transfer) = self.active_transfer + && transfer.handle_proof(data) + { + self.active_transfer = None; + } + } + rns_wire::context::PacketContext::Response => { + if self.state == SyncTaskState::AwaitingResponse + && let Some(ref mut link) = self.link + && let Ok((_request_id, response_data)) = link.handle_response(data) + { + let offer_response = OfferResponse::from_msgpack(&response_data); + self.handle_offer_response(offer_response); + } + } + rns_wire::context::PacketContext::LinkClose => { + self.handle_link_closed(header.destination_hash, Some(data)); + } + _ => {} + } + } + _ => {} + } + } + } + + fn handle_link_closed(&mut self, link_id: [u8; 16], encrypted_teardown: Option<&[u8]>) -> bool { + if self.link_id != Some(link_id) { + return false; + } + + let Some(link) = self.link.as_mut() else { + return false; + }; + + let verified = match encrypted_teardown { + Some(data) => link.receive_teardown(data), + None => { + link.mark_closed(CloseReason::DestinationClosed); + true + } + }; + + if verified { + self.active_transfer = None; + self.transfer_queue.clear(); + self.state = SyncTaskState::Failed; + } + + verified + } + + fn handle_link_proof( + &mut self, + proof_data: &[u8], + verify_key: &Ed25519PublicKey, + ed25519_pub: &[u8; 32], + ) { + let link = match self.link.as_mut() { + Some(l) => l, + None => return, + }; + + match link.validate_proof(proof_data, verify_key, ed25519_pub) { + Ok(rtt_data) => { + // RTT packet = message 3 of the link handshake. + if let Some(link_id) = self.link_id { + let rtt_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Lrrtt, + }; + let mut rtt_raw = rtt_header.pack(); + rtt_raw.extend_from_slice(&rtt_data); + + let _ = + self.transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(rtt_raw), + destination_hash: link_id, + })); + + // Python LXMPeer.py:530-538 + let establishment_rate = link.rtt.map(|d| { + let secs = d.as_secs_f64(); + if secs > 0.0 { 1.0 / secs } else { 0.0 } + }); + if let Some(ref mut peer) = self.peer { + peer.link_established(link_id, establishment_rate); + } + } + self.state = SyncTaskState::Offering; + } + Err(_) => { + self.state = SyncTaskState::Failed; + } + } + } + + /// Python reference: LXMPeer.py:396-439 (offer_response). + fn handle_offer_response(&mut self, response: OfferResponse) { + let node_hash = match self.node_dest_hash { + Some(h) => h, + None => return, + }; + + match response { + OfferResponse::WantAll => { + let all_ids = self.propagation_node.create_offer(node_hash, None); + self.queue_messages_for_ids(&all_ids); + } + OfferResponse::HaveAll => { + self.state = SyncTaskState::Complete; + } + OfferResponse::WantSome(wanted_id_bytes) => { + let wanted_ids: Vec<[u8; 16]> = wanted_id_bytes + .iter() + .filter_map(|id| { + if id.len() == 16 { + let mut arr = [0u8; 16]; + arr.copy_from_slice(id); + Some(arr) + } else { + None + } + }) + .collect(); + self.queue_messages_for_ids(&wanted_ids); + } + _ => { + self.state = SyncTaskState::Failed; + } + } + } + + fn queue_messages_for_ids(&mut self, ids: &[[u8; 16]]) { + let results = self.propagation_node.message_get_request(ids); + self.transfer_queue = results.into_iter().map(|(_tid, data)| data).collect(); + + if self.transfer_queue.is_empty() { + self.state = SyncTaskState::Complete; + } else { + self.state = SyncTaskState::Transferring; + } + } + + pub fn tick(&mut self) { + if let Some(started) = self.sync_started + && started.elapsed() > self.sync_timeout + && self.state != SyncTaskState::Idle + { + self.cleanup_sync(); + self.state = SyncTaskState::Failed; + return; + } + + match self.state { + SyncTaskState::Idle => { + if self.last_sync.elapsed() >= self.sync_interval + && let Some(node_hash) = self.node_dest_hash + { + if self.propagation_node.message_count() > 0 { + self.start_sync(node_hash); + } else { + self.last_sync = Instant::now(); + } + } + } + SyncTaskState::Establishing | SyncTaskState::AwaitingResponse => {} + SyncTaskState::Offering => { + self.send_offer_request(); + } + SyncTaskState::Transferring => { + self.drive_transfers(); + } + SyncTaskState::Complete | SyncTaskState::Failed => { + self.cleanup_sync(); + self.last_sync = Instant::now(); + self.state = SyncTaskState::Idle; + } + } + } + + /// Python reference: LXMPeer.py:381-386. + fn send_offer_request(&mut self) { + let node_hash = match self.node_dest_hash { + Some(h) => h, + None => { + self.state = SyncTaskState::Failed; + return; + } + }; + + // Wire: msgpack([peering_key, [transient_id_1, transient_id_2, ...]]) + let offer = self.propagation_node.prepare_sync_offer(node_hash); + let offer_data = { + use rmpv::Value; + let ids: Vec = offer + .transient_ids + .iter() + .map(|id| Value::Binary(id.clone())) + .collect(); + let array = Value::Array(vec![ + Value::Binary(offer.peering_key.clone()), + Value::Array(ids), + ]); + crate::encode_value(&array) + }; + + if let Some(ref mut link) = self.link { + match link.request( + OFFER_REQUEST_PATH, + Some(&offer_data), + Duration::from_secs(60), + ) { + Ok((encrypted, _request_id)) => { + if let Some(link_id) = self.link_id { + let req_header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::Request, + }; + let mut req_raw = req_header.pack(); + req_raw.extend_from_slice(&encrypted); + let packet_request_id = rns_wire::hash::truncated_packet_hash( + &req_raw, + rns_wire::flags::HeaderType::Header1, + ); + link.update_pending_request_id(&_request_id, packet_request_id); + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + OutboundRequest { + raw: Bytes::from(req_raw), + destination_hash: link_id, + }, + )); + } + self.state = SyncTaskState::AwaitingResponse; + } + Err(_) => { + self.state = SyncTaskState::Failed; + } + } + } else { + self.state = SyncTaskState::Failed; + } + } + + fn start_sync(&mut self, node_hash: [u8; 16]) { + let (link, request_data) = Link::new_initiator(node_hash, 1); + let link_id = link.link_id; + + if let Err(e) = self + .transport_tx + .try_send(TransportMessage::RegisterDestination { + hash: link_id, + app_name: "lxmf.propagation.sync".to_string(), + delivery_tx: Some(self.event_tx.clone()), + }) + { + tracing::warn!(err = %e, + "failed to register propagation sync destination; sync will fail"); + } + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::LinkRequest, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: node_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&request_data); + + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: node_hash, + })); + + let mut peer = LxmPeer::new(node_hash); + peer.begin_sync(); + + self.link = Some(link); + self.link_id = Some(link_id); + self.peer = Some(peer); + self.state = SyncTaskState::Establishing; + self.sync_started = Some(Instant::now()); + } + + fn drive_transfers(&mut self) { + if self.active_transfer.is_none() { + if let Some(msg_data) = self.transfer_queue.pop() { + let rtt = self + .link + .as_ref() + .and_then(|l| l.rtt) + .unwrap_or(Duration::from_millis(500)); + match OutboundTransfer::new(msg_data, true, rtt) { + Ok(transfer) => { + self.active_transfer = Some(transfer); + } + Err(_) => return, + } + } else { + self.state = SyncTaskState::Complete; + return; + } + } + + if let Some(ref mut transfer) = self.active_transfer { + let action = transfer.tick(); + match action { + TransferAction::SendAdvertisement(adv_data) => { + self.send_resource_packet( + &adv_data, + rns_wire::context::PacketContext::ResourceAdv, + ); + } + TransferAction::SendPart(_, part_data) => { + self.send_resource_packet( + &part_data, + rns_wire::context::PacketContext::Resource, + ); + } + TransferAction::Complete => { + self.active_transfer = None; + } + TransferAction::Failed(_) => { + self.active_transfer = None; + self.state = SyncTaskState::Failed; + } + _ => {} + } + } + } + + fn send_resource_packet(&self, data: &[u8], context: rns_wire::context::PacketContext) { + let link_id = match self.link_id { + Some(id) => id, + None => return, + }; + let link = match self.link.as_ref() { + Some(l) => l, + None => return, + }; + + if let Ok(encrypted) = link.encrypt(data) { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&encrypted); + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: link_id, + })); + } + } + + /// Python LXMPeer.py:540-542. + fn cleanup_sync(&mut self) { + self.send_teardown(); + if let Some(ref mut peer) = self.peer { + peer.link_closed(); + } + + if let Some(link_id) = self.link_id.take() { + let _ = self + .transport_tx + .try_send(TransportMessage::DeregisterDestination { hash: link_id }); + } + self.link = None; + self.peer = None; + self.active_transfer = None; + self.transfer_queue.clear(); + self.sync_started = None; + } + + fn send_teardown(&mut self) { + let Some(link_id) = self.link_id else { + return; + }; + let teardown_data = self + .link + .as_mut() + .and_then(|link| link.teardown(CloseReason::InitiatorClosed)); + if let Some(teardown_data) = teardown_data { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context: rns_wire::context::PacketContext::LinkClose, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&teardown_data); + let _ = self + .transport_tx + .try_send(TransportMessage::Outbound(OutboundRequest { + raw: Bytes::from(raw), + destination_hash: link_id, + })); + } + } + + pub fn message_count(&self) -> usize { + self.propagation_node.message_count() + } + + pub fn peer(&self) -> Option<&LxmPeer> { + self.peer.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn active_link_pair(dest_hash: [u8; 16]) -> (Link, Link) { + let responder_key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let responder_pub = responder_key.public_key(); + let (mut initiator, request_data) = Link::new_initiator(dest_hash, 1); + let (mut responder, proof_data) = + Link::new_responder(&request_data, &responder_key, dest_hash, 1).unwrap(); + let rtt_data = initiator + .validate_proof(&proof_data, &responder_pub, &responder_pub.to_bytes()) + .unwrap(); + responder.receive_rtt_packet(&rtt_data).unwrap(); + (initiator, responder) + } + + fn link_data_packet( + link_id: [u8; 16], + context: rns_wire::context::PacketContext, + payload: &[u8], + ) -> Bytes { + let header = rns_wire::header::PacketHeader { + flags: rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Link, + packet_type: rns_wire::flags::PacketType::Data, + }, + hops: 0, + transport_id: None, + destination_hash: link_id, + context, + }; + let mut raw = header.pack(); + raw.extend_from_slice(payload); + Bytes::from(raw) + } + + fn make_sync_due(task: &mut PropagationSyncTask) { + task.sync_interval = Duration::ZERO; + task.last_sync = Instant::now(); + } + + #[test] + fn test_sync_task_creation() { + let (tx, _rx) = mpsc::channel(16); + let task = PropagationSyncTask::new(tx, [0xAA; 16]); + assert_eq!(task.state, SyncTaskState::Idle); + assert_eq!(task.message_count(), 0); + } + + #[test] + fn test_set_node() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + assert!(task.node_dest_hash.is_none()); + + task.set_node([0xBB; 16]); + assert_eq!(task.node_dest_hash, Some([0xBB; 16])); + } + + #[test] + fn test_accept_message() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "sync test content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + + assert!(task.accept_message(&msg)); + assert_eq!(task.message_count(), 1); + } + + #[test] + fn test_idle_no_node_configured() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.tick(); + assert_eq!(task.state, SyncTaskState::Idle); + } + + #[test] + fn test_idle_no_messages() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + task.tick(); + assert_eq!(task.state, SyncTaskState::Idle); + } + + #[test] + fn test_starts_sync_when_ready() { + let (tx, mut rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.tick(); + assert_eq!(task.state, SyncTaskState::Establishing); + assert!(task.link_id.is_some()); + + let reg = rx.try_recv(); + assert!(matches!( + reg.unwrap(), + TransportMessage::RegisterDestination { .. } + )); + let outbound = rx.try_recv(); + assert!(matches!(outbound.unwrap(), TransportMessage::Outbound(_))); + } + + #[test] + fn test_sync_timeout() { + let (tx, _rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.tick(); + assert_eq!(task.state, SyncTaskState::Establishing); + + task.sync_timeout = Duration::ZERO; + + task.tick(); + assert_eq!(task.state, SyncTaskState::Failed); + + task.tick(); + assert_eq!(task.state, SyncTaskState::Idle); + } + + #[test] + fn test_cleanup_deregisters() { + let (tx, mut rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.tick(); + while rx.try_recv().is_ok() {} + + task.state = SyncTaskState::Complete; + task.tick(); + + let dereg = rx.try_recv(); + assert!(matches!( + dereg.unwrap(), + TransportMessage::DeregisterDestination { .. } + )); + } + + #[test] + fn test_authenticated_remote_link_close_fails_and_cleans_up() { + let (tx, mut rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + let node_hash = [0xE3; 16]; + let (link, mut responder_link) = active_link_pair(node_hash); + let link_id = link.link_id; + task.set_node(node_hash); + task.link = Some(link); + task.link_id = Some(link_id); + task.state = SyncTaskState::AwaitingResponse; + task.sync_started = Some(Instant::now()); + let mut peer = LxmPeer::new(node_hash); + peer.begin_sync(); + task.peer = Some(peer); + + let close_body = responder_link + .teardown(CloseReason::InitiatorClosed) + .expect("remote active link emits authenticated teardown"); + task.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet( + link_id, + rns_wire::context::PacketContext::LinkClose, + &close_body, + ), + interface_id: 0, + }) + .unwrap(); + + task.drain_events(&HashMap::new()); + assert_eq!(task.state, SyncTaskState::Failed); + + task.tick(); + assert_eq!(task.state, SyncTaskState::Idle); + assert!(task.link.is_none()); + assert!(matches!( + rx.try_recv().unwrap(), + TransportMessage::DeregisterDestination { hash } if hash == link_id + )); + } + + #[test] + fn test_unauthenticated_link_close_is_ignored() { + let (tx, _rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + let node_hash = [0xE4; 16]; + let (link, _responder_link) = active_link_pair(node_hash); + let link_id = link.link_id; + task.set_node(node_hash); + task.link = Some(link); + task.link_id = Some(link_id); + task.state = SyncTaskState::AwaitingResponse; + + task.event_tx + .try_send(DestinationEvent::InboundPacket { + raw: link_data_packet(link_id, rns_wire::context::PacketContext::LinkClose, &[0u8]), + interface_id: 0, + }) + .unwrap(); + + task.drain_events(&HashMap::new()); + assert_eq!(task.state, SyncTaskState::AwaitingResponse); + assert!(task.link.is_some()); + } + + #[test] + fn test_handle_offer_response_have_all() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + task.state = SyncTaskState::AwaitingResponse; + + task.handle_offer_response(OfferResponse::HaveAll); + assert_eq!(task.state, SyncTaskState::Complete); + } + + #[test] + fn test_handle_offer_response_error() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + task.state = SyncTaskState::AwaitingResponse; + + task.handle_offer_response(OfferResponse::ErrorNoAccess); + assert_eq!(task.state, SyncTaskState::Failed); + } + + #[test] + fn test_handle_offer_response_want_all_no_storage() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + task.state = SyncTaskState::AwaitingResponse; + + // In-memory store -- message_get_request returns empty, so WantAll -> Complete. + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.handle_offer_response(OfferResponse::WantAll); + assert_eq!(task.state, SyncTaskState::Complete); + } + + #[test] + fn test_handle_offer_response_want_some() { + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + task.state = SyncTaskState::AwaitingResponse; + + let wanted = vec![vec![0x11; 16], vec![0x22; 16]]; + task.handle_offer_response(OfferResponse::WantSome(wanted)); + assert_eq!(task.state, SyncTaskState::Complete); + } + + #[test] + fn test_handle_offer_response_want_some_with_storage() { + let dir = std::env::temp_dir().join("lxmf_test_sync_want_some"); + let _ = std::fs::remove_dir_all(&dir); + + let (tx, _rx) = mpsc::channel(16); + let mut task = PropagationSyncTask::with_storage(tx, [0xAA; 16], dir.clone()).unwrap(); + task.set_node([0xBB; 16]); + task.state = SyncTaskState::AwaitingResponse; + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "want some content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + let tid = rns_crypto::sha::truncated_hash(&msg.hash.unwrap()); + task.accept_message(&msg); + + let wanted = vec![tid.to_vec()]; + task.handle_offer_response(OfferResponse::WantSome(wanted)); + assert_eq!(task.state, SyncTaskState::Transferring); + assert_eq!(task.transfer_queue.len(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_peer_created_on_sync_start() { + let (tx, _rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + + assert!(task.peer().is_none()); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.tick(); + assert_eq!(task.state, SyncTaskState::Establishing); + + let peer = task.peer().expect("peer should exist after sync start"); + assert_eq!(peer.destination_hash, [0xBB; 16]); + assert_eq!(peer.state, crate::constants::PeerState::LinkEstablishing); + } + + #[test] + fn request_sync_now_starts_without_waiting_for_interval() { + let (tx, _rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + + task.request_sync_now([0xBB; 16]); + + assert_eq!(task.node_dest_hash(), Some([0xBB; 16])); + assert_eq!(task.state, SyncTaskState::Establishing); + let peer = task.peer().expect("peer should exist after forced sync"); + assert_eq!(peer.destination_hash, [0xBB; 16]); + } + + #[test] + fn test_peer_cleared_on_cleanup() { + let (tx, mut rx) = mpsc::channel(64); + let mut task = PropagationSyncTask::new(tx, [0xAA; 16]); + task.set_node([0xBB; 16]); + make_sync_due(&mut task); + + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = crate::message::LxMessage::new( + [0xBB; 16], + [0xCC; 16], + "Test", + "content", + crate::constants::DeliveryMethod::Propagated, + ); + msg.sign(&key).unwrap(); + task.accept_message(&msg); + + task.tick(); + while rx.try_recv().is_ok() {} + + assert!(task.peer().is_some()); + + task.state = SyncTaskState::Complete; + task.tick(); + + assert!(task.peer().is_none()); + } +} diff --git a/crates/lxmf-core/src/router.rs b/crates/lxmf-core/src/router.rs new file mode 100644 index 0000000..851c3ed --- /dev/null +++ b/crates/lxmf-core/src/router.rs @@ -0,0 +1,2251 @@ +//! LXMF Router: message delivery engine and propagation node. +//! +//! Python reference: LXMF/LXMRouter.py. Actor pattern — a single tokio task owns +//! all mutable state. + +use std::collections::HashMap; + +use bytes::Bytes; +use tokio::sync::{mpsc, oneshot}; + +use crate::constants::*; +use crate::message::LxMessage; +use crate::peer::LxmPeer; +use crate::propagation::PropagationStore; +use crate::stamper; +use crate::ticket::{Ticket, TicketStore}; + +/// Router configuration. +/// +/// Core fields are stable for downstream compatibility; additional Python +/// `LXMRouter.__init__` knobs live in [`RouterConfigExt`] behind the `ext` field. +#[derive(Debug, Clone)] +pub struct RouterConfig { + pub propagation_enabled: bool, + pub autopeer: bool, + pub max_peers: usize, + pub propagation_limit_kb: usize, + pub delivery_limit_kb: usize, + pub sync_limit_kb: usize, + pub propagation_stamp_cost: u8, + pub propagation_stamp_flex: u8, + pub stamp_cost: Option, + pub ext: RouterConfigExt, +} + +/// Extended router configuration. +/// +/// Additional `LXMRouter.__init__` fields; all have sensible defaults. +#[derive(Debug, Clone)] +pub struct RouterConfigExt { + pub autopeer_maxdepth: usize, + pub propagation_cost_min: u8, + pub peering_cost: u8, + pub max_peering_cost: u8, + pub processing_outbound: bool, + /// Maximum outbound messages to process per tick (`None` = unlimited). + pub processing_limit: Option, + /// Maximum message size in bytes (`None` = unlimited). + pub max_message_size: Option, + pub enforce_ratchets: bool, + pub enforce_stamps: bool, + pub retain_synced_on_node: bool, + pub auth_required: bool, + /// Generate outbound message PoW stamps through the router deferred-stamp queue. + pub defer_stamp_generation: bool, + /// Propagation storage cap in bytes (`None` = unlimited). + pub message_storage_limit: Option, + /// Name advertised in propagation announce metadata. + pub name: Option, + pub from_static_only: bool, +} + +impl Default for RouterConfigExt { + fn default() -> Self { + Self { + autopeer_maxdepth: AUTOPEER_MAXDEPTH, + propagation_cost_min: PROPAGATION_COST_MIN, + peering_cost: PEERING_COST, + max_peering_cost: MAX_PEERING_COST, + processing_outbound: true, + processing_limit: None, + max_message_size: None, + enforce_ratchets: false, + enforce_stamps: false, + retain_synced_on_node: false, + auth_required: false, + defer_stamp_generation: true, + message_storage_limit: None, + name: None, + from_static_only: false, + } + } +} + +impl Default for RouterConfig { + fn default() -> Self { + Self { + propagation_enabled: false, + autopeer: AUTOPEER, + max_peers: MAX_PEERS, + propagation_limit_kb: PROPAGATION_LIMIT, + delivery_limit_kb: DELIVERY_LIMIT, + sync_limit_kb: SYNC_LIMIT, + propagation_stamp_cost: PROPAGATION_COST, + propagation_stamp_flex: PROPAGATION_COST_FLEX, + stamp_cost: None, + ext: RouterConfigExt::default(), + } + } +} + +pub struct DeferredStampJob { + pub message_hash: [u8; 32], + handle: stamper::DeferredStampHandle, + rx: oneshot::Receiver, +} + +/// LXMF router — owns all mutable state under the actor pattern. +pub struct LxmRouter { + pub config: RouterConfig, + pub pending_outbound: Vec, + /// Messages awaiting deferred stamp generation, keyed by message hash. + pub pending_deferred_stamps: HashMap<[u8; 32], LxMessage>, + pub active_deferred_stamp: Option, + /// Identities allowed for delivery. An empty list means "all allowed". + pub allowed: Vec<[u8; 16]>, + pub blocked: Vec<[u8; 16]>, + pub allowed_control: Vec<[u8; 16]>, + pub ignored: Vec<[u8; 16]>, + pub peers: HashMap<[u8; 16], LxmPeer>, + /// Peers that will never be rotated out. + pub static_peers: Vec<[u8; 16]>, + pub propagation_store: PropagationStore, + /// Cached stamp costs keyed by destination hash. + pub outbound_stamp_costs: HashMap<[u8; 16], StampCostEntry>, + pub ticket_store: TicketStore, + /// Identity hash → priority level. + pub prioritized: HashMap<[u8; 16], u8>, + pub delivery_callback: Option, + pub transport_tx: Option>, + /// Throttled peers → expiry timestamp (seconds since UNIX epoch). + pub throttled_peers: HashMap<[u8; 16], f64>, + pub propagation_start_time: Option, + pub processing_count: u64, + pub outbound_propagation_node: Option<[u8; 16]>, + pub propagation_transfer_state: PropagationRetrievalState, + /// Progress in the range 0.0..=1.0. + pub propagation_transfer_progress: f64, + pub client_propagation_messages_received: u64, + pub client_propagation_messages_served: u64, + pub unpeered_propagation_incoming: u64, + pub unpeered_propagation_rx_bytes: u64, +} + +/// Callback invoked when a message is delivered locally. +pub type DeliveryCallback = Box; + +/// Announce-derived data used to create an autopeered propagation peer. +pub struct AutopeerCandidate { + pub destination_hash: [u8; 16], + pub timebase: f64, + pub transfer_limit: Option, + pub sync_limit: Option, + pub stamp_cost: Option, + pub stamp_flexibility: Option, + pub peering_cost: Option, + pub hops: Option, +} + +/// Cached stamp cost for a destination. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StampCostEntry { + pub cost: u8, + pub recorded_at: f64, +} + +impl LxmRouter { + pub fn new(config: RouterConfig) -> Self { + Self { + config, + pending_outbound: Vec::new(), + pending_deferred_stamps: HashMap::new(), + active_deferred_stamp: None, + allowed: Vec::new(), + blocked: Vec::new(), + allowed_control: Vec::new(), + ignored: Vec::new(), + peers: HashMap::new(), + static_peers: Vec::new(), + propagation_store: PropagationStore::new(), + outbound_stamp_costs: HashMap::new(), + ticket_store: TicketStore::new(), + prioritized: HashMap::new(), + delivery_callback: None, + transport_tx: None, + throttled_peers: HashMap::new(), + propagation_start_time: None, + processing_count: 0, + outbound_propagation_node: None, + propagation_transfer_state: PropagationRetrievalState::Idle, + propagation_transfer_progress: 0.0, + client_propagation_messages_received: 0, + client_propagation_messages_served: 0, + unpeered_propagation_incoming: 0, + unpeered_propagation_rx_bytes: 0, + } + } + + pub fn set_transport(&mut self, tx: mpsc::Sender) { + self.transport_tx = Some(tx); + } + + pub fn has_transport(&self) -> bool { + self.transport_tx.is_some() + } + + /// Queue a message for outbound delivery. + /// + /// Opportunistic messages that exceed the single-packet ceiling are + /// transparently downgraded to Direct delivery. + #[tracing::instrument( + level = "debug", + name = "router.send", + skip_all, + fields( + destination_hash = %hex::encode(&message.destination_hash[..8]), + method = ?message.method, + content_len = message.content.len(), + ), + )] + pub fn send(&mut self, mut message: LxMessage) { + let now = now_f64(); + if message.outbound_ticket.is_none() + && let Some(ticket) = self.ticket_store.find(&message.destination_hash, now) + { + message.outbound_ticket = Some(ticket.token); + } + + if message.stamp.is_none() && message.stamp_cost.is_none() { + message.stamp_cost = self + .get_stamp_cost(&message.destination_hash) + .filter(|cost| *cost > 0); + } + + if message.stamp.is_none() + && message.outbound_ticket.is_some() + && (message.message_id.is_some() || message.compute_hash().is_ok()) + { + message.get_stamp(); + } + + if message.stamp.is_none() + && message.stamp_cost.unwrap_or(0) > 0 + && (message.message_id.is_some() || message.compute_hash().is_ok()) + { + if self.config.ext.defer_stamp_generation { + if let Some(message_hash) = message.message_id.or(message.hash) { + message.state = MessageState::Outbound; + self.pending_deferred_stamps.insert(message_hash, message); + return; + } + } else { + message.get_stamp(); + } + } + + if message.method == DeliveryMethod::Opportunistic + && let Ok(packed) = message.pack_payload() + { + let content_size = packed + .len() + .saturating_sub(TIMESTAMP_SIZE + STRUCT_OVERHEAD); + // Approximates ENCRYPTED_PACKET_MAX_CONTENT for default RNS parameters. + let max_content = 295; + if content_size > max_content { + message.method = DeliveryMethod::Direct; + } + } + + self.pending_outbound.push(message); + } + + /// Process deferred outbound message stamp generation. + /// + /// Python reference: `LXMRouter.process_deferred_stamps` — LXMRouter.py:2407-2498. + pub fn process_deferred_stamps(&mut self) { + self.poll_active_deferred_stamp(); + if self.active_deferred_stamp.is_some() { + return; + } + + let Some((&message_hash, message)) = self.pending_deferred_stamps.iter().next() else { + return; + }; + let cost = message.stamp_cost.unwrap_or(0); + if cost == 0 { + if let Some(mut message) = self.pending_deferred_stamps.remove(&message_hash) { + message.get_stamp(); + self.pending_outbound.push(message); + } + return; + } + + if tokio::runtime::Handle::try_current().is_ok() { + let (handle, rx) = + stamper::spawn_deferred_stamp(message_hash, cost, STAMP_WORKBLOCK_EXPAND_ROUNDS); + self.active_deferred_stamp = Some(DeferredStampJob { + message_hash, + handle, + rx, + }); + } else if let Some(mut message) = self.pending_deferred_stamps.remove(&message_hash) { + match stamper::generate_stamp(&message_hash, cost, STAMP_WORKBLOCK_EXPAND_ROUNDS) { + Some((stamp, value)) => { + message.stamp = Some(stamp.to_vec()); + message.stamp_value = Some(value as u16); + self.pending_outbound.push(message); + } + None => { + message.mark_failed(); + } + } + } + } + + fn poll_active_deferred_stamp(&mut self) { + let Some(mut job) = self.active_deferred_stamp.take() else { + return; + }; + + match job.rx.try_recv() { + Ok(stamper::DeferredStampResult::Success { stamp, value }) => { + if let Some(mut message) = self.pending_deferred_stamps.remove(&job.message_hash) { + message.stamp = Some(stamp.to_vec()); + message.stamp_value = Some(value as u16); + self.pending_outbound.push(message); + } + } + Ok(stamper::DeferredStampResult::Cancelled) => { + if let Some(mut message) = self.pending_deferred_stamps.remove(&job.message_hash) { + message.cancel(); + } + } + Err(oneshot::error::TryRecvError::Empty) => { + self.active_deferred_stamp = Some(job); + } + Err(oneshot::error::TryRecvError::Closed) => { + if let Some(mut message) = self.pending_deferred_stamps.remove(&job.message_hash) { + message.mark_failed(); + } + } + } + } + + pub fn allow(&mut self, identity_hash: [u8; 16]) { + if !self.allowed.contains(&identity_hash) { + self.allowed.push(identity_hash); + } + } + + pub fn disallow(&mut self, identity_hash: &[u8; 16]) { + self.allowed.retain(|h| h != identity_hash); + } + + pub fn allow_control(&mut self, identity_hash: [u8; 16]) { + if !self.allowed_control.contains(&identity_hash) { + self.allowed_control.push(identity_hash); + } + } + + pub fn disallow_control(&mut self, identity_hash: &[u8; 16]) { + self.allowed_control.retain(|h| h != identity_hash); + } + + pub fn ignore_destination(&mut self, dest_hash: [u8; 16]) { + if !self.ignored.contains(&dest_hash) { + self.ignored.push(dest_hash); + } + self.propagation_store.ignore_destination(dest_hash); + } + + pub fn unignore_destination(&mut self, dest_hash: &[u8; 16]) { + self.ignored.retain(|h| h != dest_hash); + self.propagation_store.unignore_destination(dest_hash); + } + + pub fn prioritise(&mut self, identity_hash: [u8; 16], level: u8) { + self.prioritized.insert(identity_hash, level); + self.propagation_store.prioritise_destination(identity_hash); + } + + pub fn unprioritise(&mut self, identity_hash: &[u8; 16]) { + self.prioritized.remove(identity_hash); + self.propagation_store + .unprioritise_destination(identity_hash); + } + + pub fn block(&mut self, identity_hash: [u8; 16]) { + if !self.blocked.contains(&identity_hash) { + self.blocked.push(identity_hash); + } + } + + pub fn unblock(&mut self, identity_hash: &[u8; 16]) { + self.blocked.retain(|h| h != identity_hash); + } + + /// An empty allow-list means "everyone not blocked is allowed". + pub fn is_allowed(&self, identity_hash: &[u8; 16]) -> bool { + if !self.blocked.contains(identity_hash) { + self.allowed.is_empty() || self.allowed.contains(identity_hash) + } else { + false + } + } + + pub fn is_control_allowed(&self, identity_hash: &[u8; 16]) -> bool { + self.allowed_control.contains(identity_hash) + } + + /// Whether delivery requires an entry in the allow-list. + /// + /// Python reference: `LXMRouter.requires_authentication` — LXMRouter.py:415-417. + pub fn requires_authentication(&self) -> bool { + self.config.ext.auth_required + } + + /// Toggle whether delivery requires an entry in the allow-list. + /// + /// Python reference: `LXMRouter.set_authentication` — LXMRouter.py:409-413. + pub fn set_authentication(&mut self, required: bool) { + self.config.ext.auth_required = required; + } + + /// Whether the node keeps synchronized messages in its propagation store. + pub fn retain_node_lxms(&self) -> bool { + self.config.ext.retain_synced_on_node + } + + /// Toggle whether the node keeps synchronized messages in its propagation store. + /// + /// Python reference: `LXMRouter.set_retain_node_lxms` — LXMRouter.py:419-420. + pub fn set_retain_node_lxms(&mut self, retain: bool) { + self.config.ext.retain_synced_on_node = retain; + } + + /// Propagation storage cap in bytes (`None` = unlimited). + pub fn message_storage_limit(&self) -> Option { + self.config.ext.message_storage_limit + } + + /// Set the propagation storage cap in bytes (`None` = unlimited). + /// + /// Python reference: `LXMRouter.set_message_storage_limit` — LXMRouter.py:423-424. + pub fn set_message_storage_limit(&mut self, limit: Option) { + self.config.ext.message_storage_limit = limit; + } + + /// Current on-disk-equivalent size of the propagation store, in bytes. + /// + /// Python reference: `LXMRouter.message_storage_size` — LXMRouter.py:437-441. + pub fn message_storage_size(&self) -> usize { + self.propagation_store.total_size() + } + + /// Generate a fresh random ticket for `destination_hash` and add it to the ticket store. + /// + /// The returned token can be shared with a peer that should bypass stamp PoW when sending + /// to this router. Default expiry is [`TICKET_EXPIRY`] seconds. + /// + /// Python reference: `LXMRouter.generate_ticket` — LXMRouter.py:1094-1108. + pub fn generate_ticket( + &mut self, + destination_hash: [u8; 16], + expiry_secs: Option, + ) -> [u8; 16] { + use rand::RngCore; + let mut token = [0u8; TICKET_LENGTH]; + rand::thread_rng().fill_bytes(&mut token); + let expires = now_f64() + expiry_secs.unwrap_or(TICKET_EXPIRY) as f64; + self.ticket_store + .add(Ticket::new(token, destination_hash, expires)); + token + } + + /// Record an externally-provided outbound ticket so the router can use it when sending. + /// + /// Python reference: `LXMRouter.remember_ticket` — LXMRouter.py:1110-1113. + pub fn remember_ticket(&mut self, destination_hash: [u8; 16], token: [u8; 16], expires: f64) { + self.ticket_store + .add(Ticket::new(token, destination_hash, expires)); + } + + /// Returns the token of the most-recently-added valid ticket for `destination_hash`. + /// + /// Python reference: `LXMRouter.get_outbound_ticket` — LXMRouter.py:1115-1123. + pub fn get_outbound_ticket(&self, destination_hash: &[u8; 16]) -> Option<[u8; 16]> { + let now = now_f64(); + self.ticket_store + .find(destination_hash, now) + .map(|t| t.token) + } + + /// Returns the expiry (Unix epoch seconds) of the valid ticket for `destination_hash`. + /// + /// Python reference: `LXMRouter.get_outbound_ticket_expiry` — LXMRouter.py:1125-1131. + pub fn get_outbound_ticket_expiry(&self, destination_hash: &[u8; 16]) -> Option { + let now = now_f64(); + self.ticket_store + .find(destination_hash, now) + .map(|t| t.expires) + } + + /// Snapshot of all stored tickets (including expired / used entries). + /// + /// Python reference: `LXMRouter.get_inbound_tickets` — LXMRouter.py:1133-1136. + pub fn get_inbound_tickets(&self) -> &[Ticket] { + self.ticket_store.all() + } + + /// Cancel an outbound message before it is sent. + /// + /// Removes the message from `pending_outbound` (or `pending_deferred_stamps`) if it is still + /// in a cancellable state. Returns `true` if the message was found and cancelled. + /// + /// Python reference: `LXMRouter.cancel_outbound` — LXMRouter.py:474-487. + pub fn cancel_outbound(&mut self, message_hash: &[u8; 32]) -> bool { + if let Some(pos) = self + .pending_outbound + .iter() + .position(|m| m.hash.as_ref() == Some(message_hash)) + { + let msg = &mut self.pending_outbound[pos]; + msg.cancel(); + self.pending_outbound.remove(pos); + return true; + } + + if let Some(mut msg) = self.pending_deferred_stamps.remove(message_hash) { + msg.cancel(); + if self + .active_deferred_stamp + .as_ref() + .is_some_and(|job| job.message_hash == *message_hash) + && let Some(job) = self.active_deferred_stamp.take() + { + job.handle.cancel(); + } + return true; + } + + false + } + + /// Get the outbound-delivery progress (0.0..=1.0) for a pending message. + /// + /// Python reference: `LXMRouter.get_outbound_progress` — LXMRouter.py:489-495. + pub fn get_outbound_progress(&self, message_hash: &[u8; 32]) -> Option { + self.pending_outbound + .iter() + .chain(self.pending_deferred_stamps.values()) + .find(|m| m.hash.as_ref() == Some(message_hash)) + .map(|m| m.progress) + } + + /// Get the cached required stamp cost for a destination (delivery). + /// + /// Returns `None` if no announce has advertised a cost for this destination. + /// + /// Python reference: `LXMRouter.get_outbound_lxm_stamp_cost` — LXMRouter.py:1138-1147. + pub fn get_outbound_lxm_stamp_cost(&self, destination_hash: &[u8; 16]) -> Option { + self.outbound_stamp_costs + .get(destination_hash) + .map(|e| e.cost) + } + + /// Get the propagation stamp cost for a message queued for propagation-node delivery. + /// + /// Returns the per-message `stamp_cost` recorded on the pending message when it was enqueued. + /// + /// Python reference: `LXMRouter.get_outbound_lxm_propagation_stamp_cost` — LXMRouter.py:1149-1156. + pub fn get_outbound_lxm_propagation_stamp_cost(&self, message_hash: &[u8; 32]) -> Option { + self.pending_outbound + .iter() + .chain(self.pending_deferred_stamps.values()) + .find(|m| m.hash.as_ref() == Some(message_hash)) + .and_then(|m| m.stamp_cost) + } + + /// Ingest an encrypted paper (`lxm://...`) URI and invoke the delivery callback as if the + /// message had arrived via the network. + /// + /// Python reference: `LXMRouter.ingest_lxm_uri` — LXMRouter.py:2370-2385. + pub fn ingest_lxm_uri( + &self, + uri: &str, + decrypt_fn: F, + ) -> Result + where + F: FnOnce(&[u8]) -> Result, crate::message::MessageError>, + { + let message = LxMessage::from_paper_uri(uri, decrypt_fn)?; + if let Some(ref cb) = self.delivery_callback { + cb(&message); + } + Ok(message) + } + + /// Register a router-wide callback fired on every inbound message delivery. + /// + /// Python reference: `LXMRouter.register_delivery_callback` — LXMRouter.py:358-359. + pub fn register_delivery_callback(&mut self, callback: F) + where + F: Fn(&LxMessage) + Send + 'static, + { + self.delivery_callback = Some(Box::new(callback)); + } + + /// Load persisted runtime state (stamp costs, tickets, dedup sets) from + /// `state_dir`. Missing files are treated as empty state. + pub fn load_state(&mut self, state_dir: &std::path::Path) -> std::io::Result<()> { + use crate::persist; + self.outbound_stamp_costs = persist::load_stamp_costs(state_dir)?; + self.ticket_store + .replace_all(persist::load_tickets(state_dir)?); + let delivered = persist::load_local_deliveries(state_dir)?; + let processed = persist::load_locally_processed(state_dir)?; + self.propagation_store + .replace_locally_delivered(delivered.keys().copied().collect()); + self.propagation_store + .replace_locally_processed(processed.keys().copied().collect()); + Ok(()) + } + + /// Persist runtime state to `state_dir` using MessagePack. Safe to call + /// periodically; each file is written atomically via rename. + pub fn save_state(&self, state_dir: &std::path::Path) -> std::io::Result<()> { + use crate::persist; + use std::time::{SystemTime, UNIX_EPOCH}; + + persist::save_stamp_costs(state_dir, &self.outbound_stamp_costs)?; + persist::save_tickets(state_dir, self.ticket_store.all())?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + let delivered: std::collections::HashMap<[u8; 16], f64> = self + .propagation_store + .locally_delivered_ids() + .iter() + .map(|id| (*id, now)) + .collect(); + let processed: std::collections::HashMap<[u8; 16], f64> = self + .propagation_store + .locally_processed_ids() + .iter() + .map(|id| (*id, now)) + .collect(); + persist::save_local_deliveries(state_dir, &delivered)?; + persist::save_locally_processed(state_dir, &processed)?; + Ok(()) + } + + /// Return peer destination hashes that are due for sync and mark each as + /// `LinkEstablishing` so concurrent calls don't double-schedule. + /// + /// Python's `LXMRouter.sync_peers()` drives network I/O directly; in Rust + /// network I/O lives outside the router, so callers (e.g. `lxmd`) feed this + /// list into whatever sync task manages link establishment. + pub fn sync_peers(&mut self) -> Vec<[u8; 16]> { + let mut due = Vec::new(); + for (hash, peer) in self.peers.iter_mut() { + if peer.alive + && peer.state == PeerState::Idle + && peer.unhandled_messages() > 0 + && peer.should_sync() + { + peer.begin_sync(); + due.push(*hash); + } + } + due + } + + pub fn add_peer(&mut self, peer: LxmPeer) -> bool { + if self.peers.len() >= self.config.max_peers { + return false; + } + self.peers.insert(peer.destination_hash, peer); + true + } + + /// Add a peer from announce data. + /// + /// The peer is added only when autopeer is enabled, the router is below + /// `max_peers`, and the announce hop count is within `autopeer_maxdepth`. + pub fn autopeer(&mut self, candidate: AutopeerCandidate) -> bool { + let AutopeerCandidate { + destination_hash, + timebase, + transfer_limit, + sync_limit, + stamp_cost, + stamp_flexibility, + peering_cost, + hops, + } = candidate; + + if !self.config.autopeer { + return false; + } + if self.peers.contains_key(&destination_hash) { + return false; + } + if let Some(h) = hops + && h as usize > self.config.ext.autopeer_maxdepth + { + return false; + } + + let peer = LxmPeer::from_announce( + destination_hash, + timebase, + transfer_limit, + sync_limit, + stamp_cost, + stamp_flexibility, + peering_cost, + ); + self.add_peer(peer) + } + + pub fn remove_peer(&mut self, destination_hash: &[u8; 16]) { + self.peers.remove(destination_hash); + } + + /// Remove a peer from both the active and static peer sets. + pub fn unpeer(&mut self, destination_hash: &[u8; 16]) { + self.peers.remove(destination_hash); + self.static_peers.retain(|h| h != destination_hash); + } + + /// Get a cached outbound stamp cost, or `None` if missing or expired. + pub fn get_stamp_cost(&self, destination_hash: &[u8; 16]) -> Option { + let entry = self.outbound_stamp_costs.get(destination_hash)?; + let now = now_f64(); + if now - entry.recorded_at < STAMP_COST_EXPIRY as f64 { + Some(entry.cost) + } else { + None + } + } + + pub fn set_stamp_cost(&mut self, destination_hash: [u8; 16], cost: u8) { + let now = now_f64(); + self.outbound_stamp_costs.insert( + destination_hash, + StampCostEntry { + cost, + recorded_at: now, + }, + ); + } + + pub fn set_propagation_enabled(&mut self, enabled: bool) { + self.config.propagation_enabled = enabled; + } + + /// Set the singular outbound propagation node used for `PROPAGATED` + /// message delivery. + pub fn set_outbound_propagation_node(&mut self, destination_hash: Option<[u8; 16]>) { + self.outbound_propagation_node = destination_hash; + } + + pub fn set_autopeer(&mut self, enabled: bool) { + self.config.autopeer = enabled; + } + + pub fn set_max_peers(&mut self, max: usize) { + self.config.max_peers = max; + } + + /// Propagation storage limit in kilobytes. + pub fn set_propagation_limit(&mut self, limit_kb: usize) { + self.config.propagation_limit_kb = limit_kb; + } + + pub fn set_stamp_requirements(&mut self, cost: u8, flex: u8) { + self.config.propagation_stamp_cost = cost; + self.config.propagation_stamp_flex = flex; + } + + pub fn set_enforce_ratchets(&mut self, enforce: bool) { + self.config.ext.enforce_ratchets = enforce; + } + + pub fn set_enforce_stamps(&mut self, enforce: bool) { + self.config.ext.enforce_stamps = enforce; + } + + /// Build propagation-node announce app_data (msgpack). + /// + /// Python reference: LXMRouter.get_propagation_node_app_data — LXMRouter.py:306-318. + pub fn get_propagation_node_app_data(&self) -> Vec { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let mut metadata = std::collections::HashMap::new(); + if let Some(ref name) = self.config.ext.name { + metadata.insert(0u8, name.as_bytes().to_vec()); + } + + let data = crate::handlers::PropagationNodeAnnounceData { + legacy: false, + node_state: self.config.propagation_enabled && !self.config.ext.from_static_only, + timebase: now, + transfer_limit: self.config.propagation_limit_kb as u64, + sync_limit: self.config.sync_limit_kb as u64, + stamp_cost: self.config.propagation_stamp_cost, + stamp_flex: self.config.propagation_stamp_flex, + peering_cost: self.config.ext.peering_cost, + metadata, + }; + + crate::handlers::get_propagation_node_app_data(&data) + } + + /// Validate a PoW stamp on an incoming message. + pub fn validate_stamp( + &self, + message_hash: &[u8; 32], + stamp: &[u8; 32], + required_cost: u8, + ) -> bool { + stamper::validate_stamp( + message_hash, + stamp, + required_cost, + STAMP_WORKBLOCK_EXPAND_ROUNDS, + ) + } + + /// Validate a stamp, accepting a matching ticket hash as a bypass. + /// + /// A ticket is matched by comparing the first 16 bytes of + /// `SHA-256(ticket.token || message_id)` against the stamp prefix; + /// otherwise falls back to PoW validation. + pub fn validate_stamp_with_tickets( + &self, + message_id: &[u8; 32], + stamp: &[u8], + required_cost: u8, + destination_hash: &[u8; 16], + ) -> bool { + let now = now_f64(); + if let Some(ticket) = self.ticket_store.find(destination_hash, now) { + let mut material = Vec::with_capacity(16 + 32); + material.extend_from_slice(&ticket.token); + material.extend_from_slice(message_id); + let expected = rns_crypto::sha::truncated_hash(&material); + if stamp == expected.as_ref() { + return true; + } + } + + let Ok(pow_stamp) = <&[u8; 32]>::try_from(stamp) else { + return false; + }; + self.validate_stamp(message_id, pow_stamp, required_cost) + } + + /// Called when a propagation transfer resource completes. + pub fn handle_resource_concluded(&mut self, peer_hash: &[u8; 16], success: bool) { + if let Some(peer) = self.peers.get_mut(peer_hash) { + if success { + if let Some(transferring) = peer.currently_transferring_messages.take() { + peer.outgoing += transferring.len() as u64; + peer.offered += transferring.len() as u64; + } + peer.heard(); + peer.sync_complete(); + } else { + peer.sync_failed(); + } + } + } + + /// Summarise propagation-node state for a control status request. + /// + /// Python reference: LXMRouter.compile_stats. + pub fn control_status(&self) -> Option { + if !self.config.propagation_enabled { + return None; + } + + let peer_stats: HashMap<[u8; 16], PeerStats> = self + .peers + .iter() + .map(|(hash, peer)| { + ( + *hash, + PeerStats { + peer_type: if self.static_peers.contains(hash) { + "static".to_string() + } else { + "discovered".to_string() + }, + state: peer.state as u8, + alive: peer.alive, + last_heard: peer.last_heard, + sync_transfer_rate: peer.sync_transfer_rate, + transfer_limit: peer.propagation_transfer_limit, + stamp_cost: peer.stamp_cost, + offered: peer.offered, + outgoing: peer.outgoing, + incoming: peer.incoming, + unhandled: peer.unhandled_messages(), + }, + ) + }) + .collect(); + + Some(NodeStats { + uptime: self + .propagation_start_time + .map(|t| now_f64() - t) + .unwrap_or(0.0), + delivery_limit: self.config.delivery_limit_kb, + propagation_limit: self.config.propagation_limit_kb, + sync_limit: self.config.sync_limit_kb, + stamp_cost: self.config.propagation_stamp_cost, + stamp_flex: self.config.propagation_stamp_flex, + peering_cost: self.config.ext.peering_cost, + message_count: self.propagation_store.len(), + message_size: self.propagation_store.total_size(), + storage_limit: self.config.ext.message_storage_limit, + total_peers: self.peers.len(), + max_peers: self.config.max_peers, + peer_stats, + }) + } + + pub fn clean_throttled_peers(&mut self) { + let now = now_f64(); + self.throttled_peers.retain(|_, expiry| now < *expiry); + } + + pub fn is_peer_throttled(&self, peer_hash: &[u8; 16]) -> bool { + if let Some(expiry) = self.throttled_peers.get(peer_hash) { + now_f64() < *expiry + } else { + false + } + } + + /// Throttle a peer for [`PN_STAMP_THROTTLE`] seconds. + pub fn throttle_peer(&mut self, peer_hash: [u8; 16]) { + self.throttled_peers + .insert(peer_hash, now_f64() + PN_STAMP_THROTTLE as f64); + } + + /// Drop idle, non-static peers with the lowest acceptance rates. + /// + /// Python reference: LXMRouter.rotate_peers. + pub fn rotate_peers(&mut self) { + let rotation_headroom = (self.config.max_peers * ROTATION_HEADROOM_PCT / 100).max(1); + let required_drops = self.peers.len() as isize + - (self.config.max_peers as isize - rotation_headroom as isize); + + if required_drops <= 0 || self.peers.len() <= 1 { + return; + } + + // Postpone rotation while a full headroom of peers has never been sync-tested. + let untested_count = self + .peers + .values() + .filter(|p| p.last_sync_attempt == 0.0) + .count(); + if untested_count >= rotation_headroom { + return; + } + + let mut drop_candidates: Vec<([u8; 16], f64)> = self + .peers + .iter() + .filter(|(hash, peer)| { + !self.static_peers.contains(hash) + && peer.state == PeerState::Idle + && peer.offered > 0 + }) + .map(|(hash, peer)| (*hash, peer.acceptance_rate())) + .collect(); + + drop_candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + let drop_count = (required_drops as usize).min(drop_candidates.len()); + for (hash, ar) in drop_candidates.into_iter().take(drop_count) { + if ar < ROTATION_AR_MAX { + self.unpeer(&hash); + } + } + } + + /// Drain pending outbound messages into [`OutboundAction`]s. + /// + /// A delivery that later fails externally (e.g. unknown destination key) + /// should be re-queued via [`send`][Self::send] with `delivery_attempts` + /// incremented. + #[tracing::instrument( + level = "debug", + name = "router.process_outbound", + skip_all, + fields(pending_count = self.pending_outbound.len()), + )] + pub fn process_outbound(&mut self) -> Vec { + if !self.config.ext.processing_outbound { + return Vec::new(); + } + + let mut actions = Vec::new(); + let mut processed = 0usize; + + let mut i = 0; + while i < self.pending_outbound.len() { + if let Some(limit) = self.config.ext.processing_limit + && processed >= limit + { + break; + } + + let msg = &self.pending_outbound[i]; + + let now = now_f64(); + + if msg.delivery_attempts >= MAX_DELIVERY_ATTEMPTS { + let mut msg = self.pending_outbound.remove(i); + msg.mark_failed(); + actions.push(OutboundAction::Failed(msg)); + processed += 1; + continue; + } + + if msg.delivery_attempts > 0 && msg.last_delivery_attempt > 0.0 { + let since_last = now - msg.last_delivery_attempt; + if since_last < DELIVERY_RETRY_WAIT as f64 { + i += 1; + continue; + } + } + + let age = now - msg.timestamp; + if age > MESSAGE_EXPIRY as f64 { + let mut msg = self.pending_outbound.remove(i); + msg.mark_failed(); + actions.push(OutboundAction::Expired(msg)); + processed += 1; + continue; + } + + // State transitions match Python LXMessage.py:476-499: + // Opportunistic -> Sent immediately (single packet, fire-and-forget). + // Direct / Propagated -> Sending (multi-step). + match msg.method { + DeliveryMethod::Direct => { + let mut msg = self.pending_outbound.remove(i); + msg.mark_sending(); + let dest_hash = msg.destination_hash; + actions.push(OutboundAction::DeliverDirect { + message: msg, + dest_hash, + }); + processed += 1; + // The next element has shifted into index i, so do not advance. + } + DeliveryMethod::Propagated => { + if let Some(peer_hash) = self.outbound_propagation_node { + let mut msg = self.pending_outbound.remove(i); + msg.mark_sending(); + actions.push(OutboundAction::DeliverPropagated { + message: msg, + prop_hash: peer_hash, + }); + processed += 1; + } else { + i += 1; + } + } + DeliveryMethod::Opportunistic => { + let mut msg = self.pending_outbound.remove(i); + msg.mark_sent(); + msg.progress = 0.50; + let dest_hash = msg.destination_hash; + actions.push(OutboundAction::DeliverOpportunistic { + message: msg, + dest_hash, + }); + processed += 1; + } + _ => { + i += 1; + } + } + } + + actions + } + + pub fn cull_stamp_costs(&mut self) { + let now = now_f64(); + self.outbound_stamp_costs + .retain(|_, e| now - e.recorded_at < STAMP_COST_EXPIRY as f64); + } + + pub fn cull_propagation(&mut self) { + self.propagation_store.cull_expired(MESSAGE_EXPIRY); + if let Some(limit) = self.config.ext.message_storage_limit { + self.propagation_store.cull_by_weight(limit); + } + } + + /// Send single-packet opportunistic actions via the configured transport. + /// + /// Direct and Propagated actions require a Reticulum link and are handled by + /// `LinkDeliveryManager` / propagation helpers in the embedding runtime. + pub fn execute_actions(&mut self, actions: Vec) { + let transport_tx = match &self.transport_tx { + Some(tx) => tx.clone(), + None => return, + }; + + for action in actions { + match action { + OutboundAction::DeliverOpportunistic { + mut message, + dest_hash, + } => { + if let Ok(packed) = message.pack() { + // Python LXMessage.__as_packet strips the destination + // hash for Opportunistic delivery because the RNS + // packet header already carries it. + let packet_payload = if packed.len() > DESTINATION_LENGTH { + &packed[DESTINATION_LENGTH..] + } else { + packed.as_slice() + }; + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Data, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: dest_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(packet_payload); + + if transport_tx + .try_send(rns_transport::messages::TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: dest_hash, + }, + )) + .is_ok() + && message.state == MessageState::Sending + { + message.mark_sent(); + message.progress = 1.0; + } + } + } + OutboundAction::DeliverDirect { .. } => { + tracing::warn!( + "Direct LXMF delivery requires LinkDeliveryManager; action left for embedding runtime" + ); + } + OutboundAction::DeliverPropagated { .. } => { + // Requires a link to a propagation node; handled outside this layer. + } + OutboundAction::Failed(_) | OutboundAction::Expired(_) => {} + } + } + } + + /// Advance one scheduler tick: drain outbound, then run periodic jobs. + pub fn tick(&mut self) { + self.processing_count += 1; + + self.process_deferred_stamps(); + let actions = self.process_outbound(); + if !actions.is_empty() { + self.execute_actions(actions); + } + + // Job cadences match the Python LXMRouter jobloop. + if self.processing_count.is_multiple_of(JOB_TRANSIENT_INTERVAL) { + self.propagation_store.clean_transient_caches(); + } + if self.processing_count.is_multiple_of(JOB_STORE_INTERVAL) + && self.config.propagation_enabled + { + self.cull_propagation(); + } + if self.processing_count.is_multiple_of(JOB_PEERSYNC_INTERVAL) { + self.clean_throttled_peers(); + } + if self.processing_count.is_multiple_of(JOB_ROTATE_INTERVAL) + && self.config.propagation_enabled + { + self.rotate_peers(); + } + } + + /// Get summary statistics. + pub fn stats(&self) -> RouterStats { + RouterStats { + pending_outbound: self.pending_outbound.len(), + pending_deferred_stamps: self.pending_deferred_stamps.len(), + peers: self.peers.len(), + propagation_entries: self.propagation_store.len(), + propagation_size: self.propagation_store.total_size(), + stamp_costs_cached: self.outbound_stamp_costs.len(), + } + } +} + +/// Action to take on an outbound message. +#[derive(Debug)] +pub enum OutboundAction { + /// Exhausted delivery attempts. + Failed(LxMessage), + /// Exceeded [`MESSAGE_EXPIRY`]. + Expired(LxMessage), + DeliverDirect { + message: LxMessage, + dest_hash: [u8; 16], + }, + DeliverPropagated { + message: LxMessage, + prop_hash: [u8; 16], + }, + /// Small enough for single-packet delivery. + DeliverOpportunistic { + message: LxMessage, + dest_hash: [u8; 16], + }, +} + +/// Router statistics. +#[derive(Debug)] +pub struct RouterStats { + pub pending_outbound: usize, + pub pending_deferred_stamps: usize, + pub peers: usize, + pub propagation_entries: usize, + pub propagation_size: usize, + pub stamp_costs_cached: usize, +} + +/// Per-peer stats for control status. +#[derive(Debug, Clone)] +pub struct PeerStats { + pub peer_type: String, + pub state: u8, + pub alive: bool, + pub last_heard: f64, + pub sync_transfer_rate: f64, + pub transfer_limit: Option, + pub stamp_cost: Option, + pub offered: u64, + pub outgoing: u64, + pub incoming: u64, + pub unhandled: u32, +} + +/// Propagation node stats. +#[derive(Debug)] +pub struct NodeStats { + pub uptime: f64, + pub delivery_limit: usize, + pub propagation_limit: usize, + pub sync_limit: usize, + pub stamp_cost: u8, + pub stamp_flex: u8, + pub peering_cost: u8, + pub message_count: usize, + pub message_size: usize, + pub storage_limit: Option, + pub total_peers: usize, + pub max_peers: usize, + pub peer_stats: HashMap<[u8; 16], PeerStats>, +} + +fn now_f64() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_router_creation() { + let router = LxmRouter::new(RouterConfig::default()); + assert!(router.pending_outbound.is_empty()); + assert!(router.peers.is_empty()); + assert!(router.propagation_store.is_empty()); + } + + #[test] + fn test_router_config_defaults() { + let config = RouterConfig::default(); + assert!(config.autopeer); + assert_eq!(config.ext.autopeer_maxdepth, 4); + assert_eq!(config.ext.propagation_cost_min, 13); + assert_eq!(config.ext.max_peering_cost, 26); + assert!(config.ext.processing_outbound); + assert!(config.ext.defer_stamp_generation); + assert!(config.ext.processing_limit.is_none()); + assert!(config.ext.max_message_size.is_none()); + assert!(!config.ext.enforce_ratchets); + assert!(!config.ext.enforce_stamps); + } + + #[test] + fn test_allow_disallow() { + let mut router = LxmRouter::new(RouterConfig::default()); + let hash = [0xAA; 16]; + + // Empty allow-list means all allowed. + assert!(router.is_allowed(&hash)); + + router.allow(hash); + assert!(router.is_allowed(&hash)); + assert!(!router.is_allowed(&[0xBB; 16])); + + router.disallow(&hash); + assert!(router.is_allowed(&hash)); + } + + #[test] + fn test_block_unblock() { + let mut router = LxmRouter::new(RouterConfig::default()); + let hash = [0xAA; 16]; + + assert!(router.is_allowed(&hash)); + + router.block(hash); + assert!(!router.is_allowed(&hash)); + + router.unblock(&hash); + assert!(router.is_allowed(&hash)); + } + + #[test] + fn test_prioritise_unprioritise() { + let mut router = LxmRouter::new(RouterConfig::default()); + let hash = [0xAA; 16]; + + router.prioritise(hash, 5); + assert_eq!(router.prioritized.get(&hash), Some(&5)); + + router.unprioritise(&hash); + assert!(!router.prioritized.contains_key(&hash)); + } + + #[test] + fn test_add_peer() { + let mut router = LxmRouter::new(RouterConfig::default()); + let peer = LxmPeer::new([0xAA; 16]); + assert!(router.add_peer(peer)); + assert_eq!(router.peers.len(), 1); + } + + #[test] + fn test_max_peers() { + let config = RouterConfig { + max_peers: 2, + ..Default::default() + }; + let mut router = LxmRouter::new(config); + + assert!(router.add_peer(LxmPeer::new([0x01; 16]))); + assert!(router.add_peer(LxmPeer::new([0x02; 16]))); + assert!(!router.add_peer(LxmPeer::new([0x03; 16]))); + } + + #[test] + fn test_stamp_cost_cache() { + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + + assert!(router.get_stamp_cost(&dest).is_none()); + + router.set_stamp_cost(dest, 12); + assert_eq!(router.get_stamp_cost(&dest), Some(12)); + } + + #[test] + fn test_send_uses_outbound_ticket_stamp_immediately() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + router.remember_ticket(dest, [0x42; 16], now_f64() + 60.0); + router.set_stamp_cost(dest, 16); + + let mut msg = LxMessage::new(dest, [0xBB; 16], "ticket", "stamp", DeliveryMethod::Direct); + msg.sign(&key).unwrap(); + router.send(msg); + + assert!(router.pending_deferred_stamps.is_empty()); + assert_eq!(router.pending_outbound.len(), 1); + let queued = &router.pending_outbound[0]; + assert_eq!(queued.stamp.as_ref().map(Vec::len), Some(TICKET_LENGTH)); + assert_eq!(queued.stamp_value, Some(COST_TICKET)); + } + + #[tokio::test] + async fn test_deferred_stamp_queue_completes_before_outbound_processing() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + router.set_stamp_cost(dest, 1); + + let mut msg = LxMessage::new(dest, [0xBB; 16], "defer", "stamp", DeliveryMethod::Direct); + msg.sign(&key).unwrap(); + let message_id = msg.message_id.unwrap(); + router.send(msg); + + assert!(router.pending_outbound.is_empty()); + assert!(router.pending_deferred_stamps.contains_key(&message_id)); + + for _ in 0..100 { + router.process_deferred_stamps(); + if router.pending_outbound.len() == 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + assert!(router.pending_deferred_stamps.is_empty()); + assert_eq!(router.pending_outbound.len(), 1); + let queued = &router.pending_outbound[0]; + assert_eq!(queued.stamp.as_ref().map(Vec::len), Some(32)); + assert!(queued.stamp_value.unwrap_or(0) >= 1); + } + + #[tokio::test] + async fn test_cancel_outbound_cancels_deferred_stamp_job() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + router.set_stamp_cost(dest, 8); + + let mut msg = LxMessage::new(dest, [0xBB; 16], "cancel", "stamp", DeliveryMethod::Direct); + msg.sign(&key).unwrap(); + let message_id = msg.message_id.unwrap(); + router.send(msg); + router.process_deferred_stamps(); + + assert!(router.active_deferred_stamp.is_some()); + assert!(router.cancel_outbound(&message_id)); + assert!(router.pending_deferred_stamps.is_empty()); + assert!(router.active_deferred_stamp.is_none()); + } + + #[test] + fn test_authentication_accessors() { + let mut router = LxmRouter::new(RouterConfig::default()); + assert!(!router.requires_authentication()); + router.set_authentication(true); + assert!(router.requires_authentication()); + } + + #[test] + fn test_retain_node_lxms_accessors() { + let mut router = LxmRouter::new(RouterConfig::default()); + assert!(!router.retain_node_lxms()); + router.set_retain_node_lxms(true); + assert!(router.retain_node_lxms()); + } + + #[test] + fn test_message_storage_limit_accessors() { + let mut router = LxmRouter::new(RouterConfig::default()); + assert!(router.message_storage_limit().is_none()); + assert_eq!(router.message_storage_size(), 0); + + router.set_message_storage_limit(Some(1024 * 1024)); + assert_eq!(router.message_storage_limit(), Some(1024 * 1024)); + } + + #[test] + fn test_ticket_api() { + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + + assert!(router.get_outbound_ticket(&dest).is_none()); + assert!(router.get_outbound_ticket_expiry(&dest).is_none()); + assert!(router.get_inbound_tickets().is_empty()); + + let token = router.generate_ticket(dest, None); + assert_eq!(router.get_outbound_ticket(&dest), Some(token)); + assert!(router.get_outbound_ticket_expiry(&dest).unwrap() > now_f64()); + assert_eq!(router.get_inbound_tickets().len(), 1); + + // remember_ticket adds another entry for the same dest. + router.remember_ticket(dest, [0x55; 16], now_f64() + 1000.0); + assert_eq!(router.get_inbound_tickets().len(), 2); + } + + #[test] + fn test_cancel_outbound() { + let mut router = LxmRouter::new(RouterConfig::default()); + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + msg.state = MessageState::Outbound; + let hash = [0x11u8; 32]; + msg.hash = Some(hash); + router.pending_outbound.push(msg); + + assert!(router.cancel_outbound(&hash)); + assert!(router.pending_outbound.is_empty()); + assert!(!router.cancel_outbound(&hash)); + } + + #[test] + fn test_get_outbound_progress() { + let mut router = LxmRouter::new(RouterConfig::default()); + let mut msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + msg.progress = 0.42; + let hash = [0x22u8; 32]; + msg.hash = Some(hash); + router.pending_outbound.push(msg); + + assert_eq!(router.get_outbound_progress(&hash), Some(0.42)); + assert_eq!(router.get_outbound_progress(&[0u8; 32]), None); + } + + #[test] + fn test_register_delivery_callback() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut router = LxmRouter::new(RouterConfig::default()); + let fired = Arc::new(AtomicBool::new(false)); + let fired_clone = fired.clone(); + router.register_delivery_callback(move |_| { + fired_clone.store(true, Ordering::Relaxed); + }); + + let msg = LxMessage::new([0xAA; 16], [0xBB; 16], "t", "c", DeliveryMethod::Direct); + (router.delivery_callback.as_ref().unwrap())(&msg); + assert!(fired.load(Ordering::Relaxed)); + } + + #[test] + fn test_ingest_lxm_uri() { + use rns_crypto::ed25519::Ed25519PrivateKey; + + let mut router = LxmRouter::new(RouterConfig::default()); + let key = Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "paper", + "hello", + DeliveryMethod::Paper, + ); + msg.sign(&key).unwrap(); + let uri = msg + .to_paper_uri(|plaintext| Ok(plaintext.to_vec())) + .unwrap(); + + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let fired = Arc::new(AtomicBool::new(false)); + let fired_clone = fired.clone(); + router.register_delivery_callback(move |_| { + fired_clone.store(true, Ordering::Relaxed); + }); + + let decoded = router + .ingest_lxm_uri(&uri, |ciphertext| Ok(ciphertext.to_vec())) + .unwrap(); + assert_eq!(decoded.title, "paper"); + assert!(fired.load(Ordering::Relaxed)); + } + + #[test] + fn test_save_and_load_state_roundtrip() { + let tmp = tempfile::TempDir::new().unwrap(); + let dest_a = [0xAA; 16]; + let transient_a = [0x11; 16]; + + let mut r1 = LxmRouter::new(RouterConfig::default()); + r1.set_stamp_cost(dest_a, 8); + // Far-future expiry so get_outbound_ticket (which uses wall-clock now) matches. + r1.remember_ticket(dest_a, [0x01; 16], 4_102_444_800.0); + r1.propagation_store.mark_locally_delivered(transient_a); + r1.propagation_store.mark_locally_processed(transient_a); + r1.save_state(tmp.path()).unwrap(); + + let mut r2 = LxmRouter::new(RouterConfig::default()); + r2.load_state(tmp.path()).unwrap(); + assert_eq!( + r2.outbound_stamp_costs.get(&dest_a).map(|e| e.cost), + Some(8) + ); + assert_eq!(r2.get_outbound_ticket(&dest_a), Some([0x01; 16])); + assert!(r2.propagation_store.is_locally_delivered(&transient_a)); + assert!(r2.propagation_store.is_locally_processed(&transient_a)); + } + + #[test] + fn test_load_state_missing_dir_is_ok() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut r = LxmRouter::new(RouterConfig::default()); + r.load_state(tmp.path()).unwrap(); + assert!(r.outbound_stamp_costs.is_empty()); + } + + #[test] + fn test_sync_peers_picks_due_peers() { + let mut router = LxmRouter::new(RouterConfig::default()); + + let mut peer_due = LxmPeer::new([0x01; 16]); + peer_due.add_unhandled_message(); + router.add_peer(peer_due); + + let peer_idle_no_msgs = LxmPeer::new([0x02; 16]); + router.add_peer(peer_idle_no_msgs); + + let mut peer_in_flight = LxmPeer::new([0x03; 16]); + peer_in_flight.add_unhandled_message(); + peer_in_flight.begin_sync(); + router.add_peer(peer_in_flight); + + let due = router.sync_peers(); + assert_eq!(due, vec![[0x01; 16]]); + + // Subsequent call returns empty — the due peer is now LinkEstablishing. + assert!(router.sync_peers().is_empty()); + } + + #[test] + fn test_validate_stamp() { + let router = LxmRouter::new(RouterConfig::default()); + let msg_id = rns_crypto::sha::sha256(b"test message"); + let stamp = + stamper::generate_stamp_limited(&msg_id, 4, STAMP_WORKBLOCK_EXPAND_ROUNDS, 1_000_000); + if let Some(stamp) = stamp { + assert!(router.validate_stamp(&msg_id, &stamp, 4)); + } + } + + #[test] + fn test_send_message() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Content", + DeliveryMethod::Direct, + ); + router.send(msg); + assert_eq!(router.pending_outbound.len(), 1); + } + + #[test] + fn test_process_outbound_max_attempts() { + let mut router = LxmRouter::new(RouterConfig::default()); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Test", + "Content", + DeliveryMethod::Direct, + ); + msg.delivery_attempts = MAX_DELIVERY_ATTEMPTS; + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + assert!(router.pending_outbound.is_empty()); + } + + #[test] + fn test_stats() { + let router = LxmRouter::new(RouterConfig::default()); + let stats = router.stats(); + assert_eq!(stats.pending_outbound, 0); + assert_eq!(stats.peers, 0); + assert_eq!(stats.propagation_entries, 0); + } + + #[test] + fn test_allow_control() { + let mut router = LxmRouter::new(RouterConfig::default()); + let hash = [0xCC; 16]; + + router.allow_control(hash); + assert!(router.is_control_allowed(&hash)); + assert!(!router.is_control_allowed(&[0xDD; 16])); + + router.disallow_control(&hash); + assert!(!router.is_control_allowed(&hash)); + } + + #[test] + fn test_set_transport() { + let mut router = LxmRouter::new(RouterConfig::default()); + assert!(!router.has_transport()); + assert!(router.transport_tx.is_none()); + + let (tx, _rx) = tokio::sync::mpsc::channel(1); + router.set_transport(tx); + assert!(router.has_transport()); + assert!(router.transport_tx.is_some()); + } + + #[test] + fn test_process_outbound_direct_delivery() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Direct", + "Content", + DeliveryMethod::Direct, + ); + router.send(msg); + + let actions = router.process_outbound(); + let has_direct = actions + .iter() + .any(|a| matches!(a, OutboundAction::DeliverDirect { .. })); + assert!(has_direct); + } + + #[test] + fn test_process_outbound_propagated_delivery() { + let mut router = LxmRouter::new(RouterConfig::default()); + let peer_hash = [0x11; 16]; + let peer = crate::peer::LxmPeer::new(peer_hash); + router.add_peer(peer); + router.set_outbound_propagation_node(Some(peer_hash)); + + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Propagated", + "Content", + DeliveryMethod::Propagated, + ); + router.send(msg); + + let actions = router.process_outbound(); + let has_propagated = actions + .iter() + .any(|a| matches!(a, OutboundAction::DeliverPropagated { .. })); + assert!(has_propagated); + } + + #[test] + fn test_process_outbound_opportunistic_delivery() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Opportunistic", + "Content", + DeliveryMethod::Opportunistic, + ); + router.send(msg); + + let actions = router.process_outbound(); + let has_opportunistic = actions + .iter() + .any(|a| matches!(a, OutboundAction::DeliverOpportunistic { .. })); + assert!(has_opportunistic); + } + + #[test] + fn test_process_outbound_propagated_no_node() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Propagated", + "Content", + DeliveryMethod::Propagated, + ); + router.send(msg); + + let actions = router.process_outbound(); + let has_propagated = actions + .iter() + .any(|a| matches!(a, OutboundAction::DeliverPropagated { .. })); + assert!(!has_propagated); + } + + /// A queued message older than `MESSAGE_EXPIRY` must be flushed as + /// `Expired` on the next `process_outbound`, marked Failed, and not + /// held indefinitely. Mirrors Python LXMRouter.process_outbound where + /// the age check runs before any delivery attempt. + #[test] + fn test_process_outbound_expired_message_marked_failed() { + let mut router = LxmRouter::new(RouterConfig::default()); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Stale", + "Content", + DeliveryMethod::Direct, + ); + // Anchor the timestamp comfortably past the expiry window. + msg.timestamp = now_f64() - (MESSAGE_EXPIRY as f64) - 60.0; + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + assert!( + matches!(&actions[0], OutboundAction::Expired(m) if m.state == MessageState::Failed), + "expired message surfaces as Expired with state=Failed, got {:?}", + actions[0] + ); + assert!( + router.pending_outbound.is_empty(), + "expired message removed from queue" + ); + } + + /// A message that has attempted delivery within the last + /// `DELIVERY_RETRY_WAIT` seconds must be skipped by `process_outbound` + /// rather than immediately retried. Prevents tight-loop reattempt + /// storms when a transport has a transient failure. + #[test] + fn test_process_outbound_retry_backoff_defers_within_window() { + let mut router = LxmRouter::new(RouterConfig::default()); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Backoff", + "Content", + DeliveryMethod::Direct, + ); + // Simulate one failed attempt very recently. + msg.delivery_attempts = 1; + msg.last_delivery_attempt = now_f64() - 1.0; // 1 s ago, inside the 10 s window. + router.send(msg); + + let actions = router.process_outbound(); + assert!( + actions.is_empty(), + "inside retry-wait window: no action emitted, got {:?}", + actions + ); + assert_eq!( + router.pending_outbound.len(), + 1, + "message stays queued for the next tick" + ); + } + + /// The state machine contract: a Direct message picked up by + /// `process_outbound` must be emitted as `DeliverDirect` with the + /// message state transitioned to `Sending` before it leaves the queue. + /// Complements `test_process_outbound_direct_delivery`, which only + /// covers the action variant without asserting on state. + #[test] + fn test_process_outbound_direct_transitions_to_sending() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Direct", + "Content", + DeliveryMethod::Direct, + ); + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + match &actions[0] { + OutboundAction::DeliverDirect { message, .. } => { + assert_eq!( + message.state, + MessageState::Sending, + "Direct message enters Sending on dequeue" + ); + } + other => panic!("expected DeliverDirect, got {:?}", other), + } + } + + #[test] + fn test_tick_sends_to_transport() { + let mut router = LxmRouter::new(RouterConfig::default()); + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + router.set_transport(tx); + + let signing_key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Tick Test", + "Content", + DeliveryMethod::Opportunistic, + ); + msg.sign(&signing_key).unwrap(); + router.send(msg); + + router.tick(); + + let received = rx.try_recv(); + assert!(received.is_ok(), "expected outbound packet from tick()"); + } + + #[test] + fn test_execute_actions_no_transport() { + let mut router = LxmRouter::new(RouterConfig::default()); + // No transport set — execute_actions must be a no-op. + let actions = vec![OutboundAction::DeliverDirect { + message: LxMessage::new([0; 16], [0; 16], "t", "c", DeliveryMethod::Direct), + dest_hash: [0; 16], + }]; + router.execute_actions(actions); + } + + #[test] + fn test_execute_actions_only_sends_opportunistic_packet_payload_shape() { + let key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let dest_hash = [0xAA; 16]; + let src_hash = [0xBB; 16]; + + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let mut router = LxmRouter::new(RouterConfig::default()); + router.set_transport(tx); + + let mut direct = LxMessage::new(dest_hash, src_hash, "Direct", "d", DeliveryMethod::Direct); + direct.sign(&key).unwrap(); + + let mut opportunistic = LxMessage::new( + dest_hash, + src_hash, + "Opp", + "o", + DeliveryMethod::Opportunistic, + ); + opportunistic.sign(&key).unwrap(); + let opportunistic_packed = opportunistic.pack().unwrap(); + + router.execute_actions(vec![ + OutboundAction::DeliverDirect { + message: direct, + dest_hash, + }, + OutboundAction::DeliverOpportunistic { + message: opportunistic, + dest_hash, + }, + ]); + + let opportunistic_raw = match rx.try_recv().expect("opportunistic outbound request") { + rns_transport::messages::TransportMessage::Outbound(req) => req.raw, + other => panic!("expected outbound request, got {other:?}"), + }; + let (_, opportunistic_data_offset) = + rns_wire::header::PacketHeader::unpack(&opportunistic_raw).unwrap(); + assert_eq!( + &opportunistic_raw[opportunistic_data_offset..], + &opportunistic_packed[DESTINATION_LENGTH..], + "Opportunistic delivery omits the destination hash already present in the RNS header" + ); + assert!( + rx.try_recv().is_err(), + "Direct actions require LinkDeliveryManager and must not be sent as destination packets" + ); + } + + #[test] + fn test_ignore_destination() { + let mut router = LxmRouter::new(RouterConfig::default()); + let dest = [0xAA; 16]; + + router.ignore_destination(dest); + assert!(router.ignored.contains(&dest)); + assert!(router.propagation_store.is_destination_ignored(&dest)); + + router.unignore_destination(&dest); + assert!(!router.ignored.contains(&dest)); + assert!(!router.propagation_store.is_destination_ignored(&dest)); + } + + #[test] + fn test_autopeer() { + let mut router = LxmRouter::new(RouterConfig::default()); + assert!(router.autopeer(AutopeerCandidate { + destination_hash: [0xAA; 16], + timebase: 1000.0, + transfer_limit: Some(256.0), + sync_limit: Some(10240.0), + stamp_cost: Some(16), + stamp_flexibility: Some(3), + peering_cost: Some(18), + hops: Some(2), + })); + assert_eq!(router.peers.len(), 1); + + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: [0xAA; 16], + timebase: 1000.0, + transfer_limit: None, + sync_limit: None, + stamp_cost: None, + stamp_flexibility: None, + peering_cost: None, + hops: None, + })); + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: [0xBB; 16], + timebase: 1000.0, + transfer_limit: None, + sync_limit: None, + stamp_cost: None, + stamp_flexibility: None, + peering_cost: None, + hops: Some(10), + })); + } + + #[test] + fn test_autopeer_respects_configured_maxdepth() { + let mut router = LxmRouter::new(RouterConfig { + ext: RouterConfigExt { + autopeer_maxdepth: 1, + ..Default::default() + }, + ..Default::default() + }); + + assert!(!router.autopeer(AutopeerCandidate { + destination_hash: [0xAA; 16], + timebase: 1000.0, + transfer_limit: None, + sync_limit: None, + stamp_cost: None, + stamp_flexibility: None, + peering_cost: None, + hops: Some(2), + })); + assert!(router.autopeer(AutopeerCandidate { + destination_hash: [0xBB; 16], + timebase: 1000.0, + transfer_limit: None, + sync_limit: None, + stamp_cost: None, + stamp_flexibility: None, + peering_cost: None, + hops: Some(1), + })); + } + + #[test] + fn test_resource_concluded() { + let mut router = LxmRouter::new(RouterConfig::default()); + let peer_hash = [0xAA; 16]; + let mut peer = LxmPeer::new(peer_hash); + peer.currently_transferring_messages = Some(vec![[0x01; 16], [0x02; 16]]); + peer.state = PeerState::ResourceTransferring; + router.add_peer(peer); + + router.handle_resource_concluded(&peer_hash, true); + + let peer = router.peers.get(&peer_hash).unwrap(); + assert_eq!(peer.state, PeerState::Idle); + assert_eq!(peer.outgoing, 2); + assert!(peer.currently_transferring_messages.is_none()); + } + + #[test] + fn test_control_status() { + let config = RouterConfig { + propagation_enabled: true, + ..Default::default() + }; + let mut router = LxmRouter::new(config); + router.propagation_start_time = Some(now_f64()); + + let stats = router.control_status(); + assert!(stats.is_some()); + let stats = stats.unwrap(); + assert_eq!(stats.total_peers, 0); + assert_eq!(stats.message_count, 0); + } + + #[test] + fn test_processing_limit() { + let mut config = RouterConfig::default(); + config.ext.processing_limit = Some(1); + let mut router = LxmRouter::new(config); + + router.send(LxMessage::new( + [0x01; 16], + [0; 16], + "a", + "b", + DeliveryMethod::Direct, + )); + router.send(LxMessage::new( + [0x02; 16], + [0; 16], + "c", + "d", + DeliveryMethod::Direct, + )); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + assert_eq!(router.pending_outbound.len(), 1); + } + + #[test] + fn test_throttle_peer() { + let mut router = LxmRouter::new(RouterConfig::default()); + let hash = [0xAA; 16]; + + assert!(!router.is_peer_throttled(&hash)); + router.throttle_peer(hash); + assert!(router.is_peer_throttled(&hash)); + } + + #[test] + fn test_opportunistic_fallback_to_direct() { + let mut router = LxmRouter::new(RouterConfig::default()); + let large_content = "x".repeat(500); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Large", + &large_content, + DeliveryMethod::Opportunistic, + ); + router.send(msg); + + assert_eq!(router.pending_outbound[0].method, DeliveryMethod::Direct); + } + + #[test] + fn test_direct_message_state_sending() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Direct", + "Content", + DeliveryMethod::Direct, + ); + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + match &actions[0] { + OutboundAction::DeliverDirect { message, .. } => { + assert_eq!(message.state, MessageState::Sending); + } + _ => panic!("expected DeliverDirect"), + } + } + + #[test] + fn test_propagated_message_state_sending() { + let mut router = LxmRouter::new(RouterConfig::default()); + let peer_hash = [0x11; 16]; + let peer = crate::peer::LxmPeer::new(peer_hash); + router.add_peer(peer); + router.set_outbound_propagation_node(Some(peer_hash)); + + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Propagated", + "Content", + DeliveryMethod::Propagated, + ); + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + match &actions[0] { + OutboundAction::DeliverPropagated { message, .. } => { + assert_eq!(message.state, MessageState::Sending); + } + _ => panic!("expected DeliverPropagated"), + } + } + + #[test] + fn test_opportunistic_message_state_sent() { + let mut router = LxmRouter::new(RouterConfig::default()); + let msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Opportunistic", + "Content", + DeliveryMethod::Opportunistic, + ); + router.send(msg); + + let actions = router.process_outbound(); + assert_eq!(actions.len(), 1); + match &actions[0] { + OutboundAction::DeliverOpportunistic { message, .. } => { + assert_eq!(message.state, MessageState::Sent); + assert_eq!(message.progress, 0.50); + } + _ => panic!("expected DeliverOpportunistic"), + } + } + + #[test] + fn test_direct_message_left_for_link_delivery_after_execute() { + let mut router = LxmRouter::new(RouterConfig::default()); + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + router.set_transport(tx); + + let signing_key = rns_crypto::ed25519::Ed25519PrivateKey::generate(); + let mut msg = LxMessage::new( + [0xAA; 16], + [0xBB; 16], + "Direct Sent", + "Content", + DeliveryMethod::Direct, + ); + msg.sign(&signing_key).unwrap(); + router.send(msg); + + router.tick(); + + assert!( + rx.try_recv().is_err(), + "Direct delivery requires LinkDeliveryManager, not router.execute_actions" + ); + } +} diff --git a/crates/lxmf-core/src/stamper.rs b/crates/lxmf-core/src/stamper.rs new file mode 100644 index 0000000..a1cba25 --- /dev/null +++ b/crates/lxmf-core/src/stamper.rs @@ -0,0 +1,583 @@ +//! LXMF Stamp system: Proof-of-Work generation and validation. +//! +//! Python reference: LXMF/LXStamper.py. +//! +//! Two workblock constructions are used: +//! - `stamp_workblock_raw`: HKDF-expand on arbitrary material. Matches Python +//! exactly for peering keys and PN stamps. +//! - `stamp_workblock`: iterative SHA-256 on a 32-byte message_id (simplified +//! construction used internally for message stamps; cheaper and deterministic). +//! +//! Validity check: `SHA-256(workblock || stamp)` must have >= `cost` leading +//! zero bits. Matches Python's `int.from_bytes(result) <= (1 << (256-cost))`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use rns_crypto::hkdf::hkdf_sha256; +use rns_crypto::sha::sha256; + +/// Parsed propagation-node stamp parts: `(stamp, lxm_data, value, workblock)`. +pub type PropagationStampParts = ([u8; 32], Vec, u32, [u8; 32]); + +/// Matches Python `LXStamper.stamp_workblock(material, expand_rounds)`: +/// +/// For each round n in 0..expand_rounds: +/// salt = SHA256(material + msgpack.packb(n)) +/// workblock += HKDF(length=256, derive_from=material, salt=salt, context=None) +/// +/// Produces `expand_rounds * 256` bytes. +pub fn stamp_workblock_raw(material: &[u8], expand_rounds: usize) -> Vec { + let mut workblock = Vec::with_capacity(expand_rounds * 256); + + for n in 0..expand_rounds { + let n_packed = pack_msgpack_uint(n); + + let mut salt_input = Vec::with_capacity(material.len() + n_packed.len()); + salt_input.extend_from_slice(material); + salt_input.extend_from_slice(&n_packed); + let salt = sha256(&salt_input); + + let chunk = hkdf_sha256(256, material, Some(&salt), None) + .expect("HKDF expand failed for stamp workblock"); + workblock.extend_from_slice(&chunk); + } + + workblock +} + +fn pack_msgpack_uint(n: usize) -> Vec { + let value = rmpv::Value::Integer(rmpv::Integer::from(n as u64)); + crate::encode_value(&value) +} + +/// `workblock = SHA-256^(expand_rounds)(message_id)`. +/// +/// Non-32-byte inputs are first hashed to produce a 32-byte starting point. +pub fn stamp_workblock(message_id: &[u8], expand_rounds: usize) -> [u8; 32] { + let mut current = if message_id.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(message_id); + arr + } else { + sha256(message_id) + }; + for _ in 0..expand_rounds { + current = sha256(¤t); + } + current +} + +fn leading_zero_bits(data: &[u8]) -> u32 { + let mut count = 0u32; + for &byte in data { + if byte == 0 { + count += 8; + } else { + count += byte.leading_zeros(); + break; + } + } + count +} + +/// Leading zero bits of `SHA-256(workblock || stamp)`. Matches Python `stamp_value()`. +pub fn stamp_value(workblock: &[u8; 32], stamp: &[u8; 32]) -> u32 { + let mut material = [0u8; 64]; + material[..32].copy_from_slice(workblock); + material[32..].copy_from_slice(stamp); + let hash = sha256(&material); + leading_zero_bits(&hash) +} + +pub fn stamp_valid(stamp: &[u8; 32], cost: u8, workblock: &[u8; 32]) -> bool { + if cost == 0 { + return true; + } + stamp_value(workblock, stamp) >= cost as u32 +} + +/// `stamp_value` counterpart for variable-length (HKDF-expanded) workblocks. +pub fn stamp_value_raw(workblock: &[u8], stamp: &[u8; 32]) -> u32 { + let mut material = Vec::with_capacity(workblock.len() + 32); + material.extend_from_slice(workblock); + material.extend_from_slice(stamp); + let hash = sha256(&material); + leading_zero_bits(&hash) +} + +/// `stamp_valid` counterpart for variable-length (HKDF-expanded) workblocks. +pub fn stamp_valid_raw(stamp: &[u8; 32], cost: u8, workblock: &[u8]) -> bool { + if cost == 0 { + return true; + } + stamp_value_raw(workblock, stamp) >= cost as u32 +} + +/// Single-threaded brute-force stamp search. Blocks until a valid stamp is found. +pub fn generate_stamp( + message_id: &[u8; 32], + cost: u8, + expand_rounds: usize, +) -> Option<([u8; 32], u32)> { + if cost == 0 { + return Some(([0u8; 32], 0)); + } + + let workblock = stamp_workblock(message_id, expand_rounds); + + loop { + let stamp: [u8; 32] = rand_bytes(); + if stamp_valid(&stamp, cost, &workblock) { + let value = stamp_value(&workblock, &stamp); + return Some((stamp, value)); + } + } +} + +/// Generate a stamp using the Python-compatible variable-length workblock. +/// +/// This is used for propagation-node stamps and peering keys, where the +/// workblock material is arbitrary bytes instead of the regular 32-byte LXMF +/// message id. +pub fn generate_stamp_raw( + material: &[u8], + cost: u8, + expand_rounds: usize, +) -> Option<([u8; 32], u32)> { + if cost == 0 { + return Some(([0u8; 32], 0)); + } + + let workblock = stamp_workblock_raw(material, expand_rounds); + + loop { + let stamp: [u8; 32] = rand_bytes(); + if stamp_valid_raw(&stamp, cost, &workblock) { + let value = stamp_value_raw(&workblock, &stamp); + return Some((stamp, value)); + } + } +} + +/// Stamp search with a configurable iteration limit (for tests). +pub fn generate_stamp_limited( + message_id: &[u8; 32], + cost: u8, + expand_rounds: usize, + max_iterations: u64, +) -> Option<[u8; 32]> { + if cost == 0 { + return Some([0u8; 32]); + } + + let workblock = stamp_workblock(message_id, expand_rounds); + + for _ in 0..max_iterations { + let stamp: [u8; 32] = rand_bytes(); + if stamp_valid(&stamp, cost, &workblock) { + return Some(stamp); + } + } + + None +} + +pub fn validate_stamp( + message_id: &[u8; 32], + stamp: &[u8; 32], + cost: u8, + expand_rounds: usize, +) -> bool { + let workblock = stamp_workblock(message_id, expand_rounds); + stamp_valid(stamp, cost, &workblock) +} + +/// Python reference: LXStamper.py:48-51 (`validate_peering_key`). +/// +/// `peering_id` = self_identity_hash || remote_identity_hash (32 bytes typical). +/// Uses `STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING` for workblock generation. +pub fn validate_peering_key(peering_id: &[u8], peering_key: &[u8; 32], target_cost: u8) -> bool { + let workblock = stamp_workblock_raw( + peering_id, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING, + ); + stamp_valid_raw(peering_key, target_cost, &workblock) +} + +/// Python reference: LXStamper.py:53-65 (`validate_pn_stamp`). +/// +/// `transient_data = lxm_data || stamp` where stamp is the last 32 bytes. +/// Uses `STAMP_WORKBLOCK_EXPAND_ROUNDS_PN` for workblock generation. +pub fn validate_pn_stamp(transient_data: &[u8], target_cost: u8) -> Option { + let stamp_size = 32; + let lxmf_overhead = crate::constants::LXMF_OVERHEAD; + + if transient_data.len() <= lxmf_overhead + stamp_size { + return None; + } + + let split = transient_data.len() - stamp_size; + let lxm_data = &transient_data[..split]; + let stamp_bytes = &transient_data[split..]; + let mut stamp = [0u8; 32]; + stamp.copy_from_slice(stamp_bytes); + + let transient_id = rns_crypto::sha::full_hash(lxm_data); + let workblock = stamp_workblock_raw( + &transient_id, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PN, + ); + + if !stamp_valid_raw(&stamp, target_cost, &workblock) { + return None; + } + let value = stamp_value_raw(&workblock, &stamp); + Some((transient_id, lxm_data.to_vec(), value, stamp)) +} + +/// Cancellation handle for a deferred PoW task. +#[derive(Clone)] +pub struct DeferredStampHandle { + cancel: Arc, +} + +impl DeferredStampHandle { + pub fn cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.cancel.load(Ordering::Relaxed) + } +} + +#[derive(Debug)] +pub enum DeferredStampResult { + Success { stamp: [u8; 32], value: u32 }, + Cancelled, +} + +/// Spawn a deferred stamp-generation task on a blocking worker. +/// +/// Returns a cancellation handle and a oneshot receiver for the result. +#[tracing::instrument( + level = "debug", + name = "stamper.compute", + skip_all, + fields( + msg_id = %hex::encode(&message_id[..8]), + cost, + expand_rounds, + ), +)] +pub fn spawn_deferred_stamp( + message_id: [u8; 32], + cost: u8, + expand_rounds: usize, +) -> ( + DeferredStampHandle, + tokio::sync::oneshot::Receiver, +) { + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = tokio::sync::oneshot::channel(); + + let cancel_flag = cancel.clone(); + tokio::task::spawn_blocking(move || { + if cost == 0 { + let _ = tx.send(DeferredStampResult::Success { + stamp: [0u8; 32], + value: 0, + }); + return; + } + + let workblock = stamp_workblock(&message_id, expand_rounds); + + loop { + if cancel_flag.load(Ordering::Relaxed) { + let _ = tx.send(DeferredStampResult::Cancelled); + return; + } + + // Check cancellation every 1000 iterations. + for _ in 0..1000 { + let stamp: [u8; 32] = rand_bytes(); + if stamp_valid(&stamp, cost, &workblock) { + let value = stamp_value(&workblock, &stamp); + let _ = tx.send(DeferredStampResult::Success { stamp, value }); + return; + } + } + } + }); + + (DeferredStampHandle { cancel }, rx) +} + +pub(crate) fn rand_bytes() -> [u8; 32] { + use rand::RngCore; + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{STAMP_WORKBLOCK_EXPAND_ROUNDS, STAMP_WORKBLOCK_EXPAND_ROUNDS_PN}; + + #[test] + fn test_workblock_deterministic() { + let id = sha256(b"test message id"); + let wb1 = stamp_workblock(&id, STAMP_WORKBLOCK_EXPAND_ROUNDS); + let wb2 = stamp_workblock(&id, STAMP_WORKBLOCK_EXPAND_ROUNDS); + assert_eq!(wb1, wb2); + } + + #[test] + fn test_workblock_different_rounds() { + let id = sha256(b"test"); + let wb1 = stamp_workblock(&id, 10); + let wb2 = stamp_workblock(&id, 20); + assert_ne!(wb1, wb2); + } + + #[test] + fn test_leading_zero_bits() { + assert_eq!(leading_zero_bits(&[0xFF]), 0); + assert_eq!(leading_zero_bits(&[0x00, 0xFF]), 8); + assert_eq!(leading_zero_bits(&[0x00, 0x00, 0xFF]), 16); + assert_eq!(leading_zero_bits(&[0x0F]), 4); + assert_eq!(leading_zero_bits(&[0x01]), 7); + assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15); + } + + #[test] + fn test_stamp_valid_cost_zero() { + let stamp = [0u8; 32]; + let workblock = [0u8; 32]; + assert!(stamp_valid(&stamp, 0, &workblock)); + } + + #[test] + fn test_generate_stamp_cost_zero() { + let id = sha256(b"test"); + let (stamp, value) = generate_stamp(&id, 0, 20).unwrap(); + assert_eq!(stamp, [0u8; 32]); + assert_eq!(value, 0); + } + + #[test] + fn test_generate_and_validate_stamp() { + let id = sha256(b"test message for stamping"); + let cost = 4; + + let stamp = generate_stamp_limited(&id, cost, STAMP_WORKBLOCK_EXPAND_ROUNDS, 1_000_000); + assert!(stamp.is_some(), "should find a stamp with cost={cost}"); + + let stamp = stamp.unwrap(); + assert!(validate_stamp( + &id, + &stamp, + cost, + STAMP_WORKBLOCK_EXPAND_ROUNDS + )); + } + + #[test] + fn test_validate_wrong_stamp() { + let id = sha256(b"test"); + let wrong_stamp = [0xFFu8; 32]; + assert!(!validate_stamp(&id, &wrong_stamp, 32, 20)); + } + + #[test] + fn test_generate_stamp_limited_fails() { + let id = sha256(b"test"); + let result = generate_stamp_limited(&id, 128, 20, 10); + assert!(result.is_none()); + } + + #[test] + fn test_stamp_value() { + let workblock = [0u8; 32]; + let stamp = [0u8; 32]; + let _value = stamp_value(&workblock, &stamp); + } + + #[test] + fn test_stamp_value_consistency() { + let id = sha256(b"consistency test"); + let cost = 4; + if let Some(stamp) = generate_stamp_limited(&id, cost, 20, 1_000_000) { + let workblock = stamp_workblock(&id, 20); + let value = stamp_value(&workblock, &stamp); + assert!(value >= cost as u32); + assert!(stamp_valid(&stamp, cost, &workblock)); + } + } + + #[test] + fn test_workblock_handles_short_input() { + let short_input = b"short"; + let wb = stamp_workblock(short_input, 10); + assert_ne!(wb, [0u8; 32]); + } + + #[test] + fn test_different_expand_round_constants() { + let id = sha256(b"test expand rounds"); + let wb_default = stamp_workblock(&id, STAMP_WORKBLOCK_EXPAND_ROUNDS); + let wb_pn = stamp_workblock(&id, STAMP_WORKBLOCK_EXPAND_ROUNDS_PN); + assert_ne!(wb_default, wb_pn); + } + + #[test] + fn test_deferred_stamp_handle_cancel() { + let handle = DeferredStampHandle { + cancel: Arc::new(AtomicBool::new(false)), + }; + assert!(!handle.is_cancelled()); + handle.cancel(); + assert!(handle.is_cancelled()); + } + + /// End-to-end: a stamp worker running a high-cost PoW must observe the + /// cancellation flag and report `DeferredStampResult::Cancelled` + /// without panicking. This exercises the worker checkpoint rather than + /// only the handle state. + #[tokio::test] + async fn test_deferred_stamp_cancelled_mid_computation() { + // Cost 32 is intentionally high enough that the worker is still + // looping when cancellation is requested. + let id = sha256(b"mid-pow cancel"); + let (handle, rx) = spawn_deferred_stamp(id, 32, 10); + + // Allow the worker to enter its inner loop. + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + handle.cancel(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(3), rx) + .await + .expect("worker should report back within 3s of cancel") + .expect("oneshot sender dropped"); + + assert!( + matches!(result, DeferredStampResult::Cancelled), + "worker must report Cancelled after handle.cancel(), got {result:?}" + ); + } + + /// Cost=0 is the degenerate case: no work to do, oneshot fires + /// immediately with a zero stamp + value. Cancelling after the fact + /// must not race or produce a spurious Cancelled result. + #[tokio::test] + async fn test_deferred_stamp_zero_cost_completes_before_cancel() { + let id = sha256(b"zero cost"); + let (handle, rx) = spawn_deferred_stamp(id, 0, 10); + + let result = tokio::time::timeout(std::time::Duration::from_secs(3), rx) + .await + .expect("cost=0 returns immediately") + .expect("oneshot sender dropped"); + + assert!( + matches!(result, DeferredStampResult::Success { value: 0, .. }), + "cost=0 returns Success with zero value, got {result:?}" + ); + // The handle remains usable after completion; cancel is a no-op here. + handle.cancel(); + assert!(handle.is_cancelled()); + } + + #[test] + fn test_stamp_workblock_raw_deterministic() { + let id = sha256(b"test workblock raw"); + let wb1 = stamp_workblock_raw(&id, 10); + let wb2 = stamp_workblock_raw(&id, 10); + assert_eq!(wb1, wb2); + assert_eq!(wb1.len(), 10 * 256); + } + + #[test] + fn test_stamp_workblock_raw_arbitrary_length() { + let short_material = b"short"; + let wb = stamp_workblock_raw(short_material, 5); + assert_eq!(wb.len(), 5 * 256); + + let wb2 = stamp_workblock_raw(short_material, 5); + assert_eq!(wb, wb2); + + let wb3 = stamp_workblock_raw(b"other", 5); + assert_ne!(wb, wb3); + } + + #[test] + fn test_stamp_workblock_raw_peering_id_length() { + let mut peering_id = Vec::with_capacity(32); + peering_id.extend_from_slice(&[0xAA; 16]); + peering_id.extend_from_slice(&[0xBB; 16]); + let wb = stamp_workblock_raw( + &peering_id, + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING, + ); + assert_eq!( + wb.len(), + crate::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING * 256 + ); + } + + #[test] + fn test_validate_peering_key_cost_zero() { + let peering_id = [0xAA; 32]; + let peering_key = [0xFF; 32]; + assert!(validate_peering_key(&peering_id, &peering_key, 0)); + } + + #[test] + fn test_validate_peering_key_invalid() { + let mut peering_id = Vec::with_capacity(32); + peering_id.extend_from_slice(&[0xAA; 16]); + peering_id.extend_from_slice(&[0xBB; 16]); + let peering_key = [0xFF; 32]; + assert!(!validate_peering_key(&peering_id, &peering_key, 32)); + } + + #[test] + fn test_validate_pn_stamp_too_short() { + let short_data = vec![0u8; crate::constants::LXMF_OVERHEAD + 32]; + assert!(validate_pn_stamp(&short_data, 0).is_none()); + } + + #[test] + fn test_validate_pn_stamp_extracts_parts() { + let lxm_data = vec![0xAB; crate::constants::LXMF_OVERHEAD + 64]; + let stamp = [0u8; 32]; + + let mut transient_data = lxm_data.clone(); + transient_data.extend_from_slice(&stamp); + + let result = validate_pn_stamp(&transient_data, 0); + assert!(result.is_some()); + + let (transient_id, extracted_lxm, value, extracted_stamp) = result.unwrap(); + assert_eq!(extracted_lxm, lxm_data); + assert_eq!(extracted_stamp, stamp); + assert_eq!(transient_id, rns_crypto::sha::full_hash(&lxm_data)); + let _ = value; + } + + #[test] + fn test_validate_pn_stamp_high_cost_fails() { + let lxm_data = vec![0xCD; crate::constants::LXMF_OVERHEAD + 100]; + let stamp = [0xFF; 32]; + + let mut transient_data = lxm_data.clone(); + transient_data.extend_from_slice(&stamp); + + let result = validate_pn_stamp(&transient_data, 32); + assert!(result.is_none()); + } +} diff --git a/crates/lxmf-core/src/sync.rs b/crates/lxmf-core/src/sync.rs new file mode 100644 index 0000000..7e17415 --- /dev/null +++ b/crates/lxmf-core/src/sync.rs @@ -0,0 +1,556 @@ +//! LXMF Propagation Sync Protocol -- Offer/Get between peers. +//! +//! 1. Peer A opens a Link to Peer B. +//! 2. A sends Offer { transient_ids }. +//! 3. B responds with one of: +//! - `true`: peer wants ALL offered messages. +//! - `false`: peer already has everything. +//! - list of transient IDs: wants those specific messages. +//! - integer error code: 0xF0 NoIdentity, 0xF1 NoAccess, 0xF3 InvalidKey, +//! 0xF4 InvalidData, 0xF5 InvalidStamp, 0xF6 Throttled. +//! 4. A sends requested messages via Resource transfer. +//! 5. B stores and sends proof. +//! +//! Python reference: LXMPeer.py (offer_response). + +use rns_protocol::channel_message::{ChannelMessageError, MessageBase}; +use serde::{Deserialize, Serialize}; + +use crate::constants::PeerError; +use crate::propagation::PropagationStore; + +pub const SYNC_MSG_OFFER: u16 = 0x0001; +pub const SYNC_MSG_GET: u16 = 0x0002; + +/// Offer message: "I have these messages". +/// +/// Python wire: `[peering_key, unhandled_ids]`. `peering_key` is the raw stamp +/// bytes (Python `self.peering_key[0]`), required by the receiver for access control. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncOffer { + pub peering_key: Vec, + pub transient_ids: Vec>, +} + +impl SyncOffer { + pub fn new() -> Self { + Self { + peering_key: Vec::new(), + transient_ids: Vec::new(), + } + } +} + +impl Default for SyncOffer { + fn default() -> Self { + Self::new() + } +} + +impl MessageBase for SyncOffer { + fn msg_type(&self) -> u16 { + SYNC_MSG_OFFER + } + + fn pack(&self) -> Vec { + rmp_serde::to_vec(self).unwrap_or_default() + } + + fn unpack(&mut self, raw: &[u8]) -> Result<(), ChannelMessageError> { + let offer: SyncOffer = + rmp_serde::from_slice(raw).map_err(|_| ChannelMessageError::UnpackFailed)?; + self.peering_key = offer.peering_key; + self.transient_ids = offer.transient_ids; + Ok(()) + } +} + +/// Get message: "Send me these messages". +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncGet { + pub wanted_ids: Vec>, +} + +impl SyncGet { + pub fn new() -> Self { + Self { + wanted_ids: Vec::new(), + } + } +} + +impl Default for SyncGet { + fn default() -> Self { + Self::new() + } +} + +impl MessageBase for SyncGet { + fn msg_type(&self) -> u16 { + SYNC_MSG_GET + } + + fn pack(&self) -> Vec { + rmp_serde::to_vec(self).unwrap_or_default() + } + + fn unpack(&mut self, raw: &[u8]) -> Result<(), ChannelMessageError> { + let get: SyncGet = + rmp_serde::from_slice(raw).map_err(|_| ChannelMessageError::UnpackFailed)?; + self.wanted_ids = get.wanted_ids; + Ok(()) + } +} + +/// Parsed offer response from a propagation node. Python: LXMPeer.py:396-439. +#[derive(Debug, Clone, PartialEq)] +pub enum OfferResponse { + WantAll, + HaveAll, + WantSome(Vec>), + ErrorNoIdentity, + ErrorNoAccess, + ErrorInvalidKey, + ErrorThrottled, + ErrorInvalidData, + ErrorInvalidStamp, + Unknown, +} + +impl OfferResponse { + pub fn from_msgpack(data: &[u8]) -> Self { + let value: rmpv::Value = match rmpv::decode::read_value(&mut &data[..]) { + Ok(v) => v, + Err(_) => return OfferResponse::Unknown, + }; + + Self::from_value(&value) + } + + pub fn from_value(value: &rmpv::Value) -> Self { + if let Some(b) = value.as_bool() { + return if b { + OfferResponse::WantAll + } else { + OfferResponse::HaveAll + }; + } + + if let Some(code) = value.as_u64() { + return match code as u8 { + 0xF0 => OfferResponse::ErrorNoIdentity, + 0xF1 => OfferResponse::ErrorNoAccess, + 0xF3 => OfferResponse::ErrorInvalidKey, + 0xF4 => OfferResponse::ErrorInvalidData, + 0xF5 => OfferResponse::ErrorInvalidStamp, + 0xF6 => OfferResponse::ErrorThrottled, + _ => OfferResponse::Unknown, + }; + } + + if let Some(arr) = value.as_array() { + let ids: Vec> = arr + .iter() + .filter_map(|v| v.as_slice().map(|s| s.to_vec())) + .collect(); + if !ids.is_empty() { + return OfferResponse::WantSome(ids); + } + return OfferResponse::HaveAll; + } + + OfferResponse::Unknown + } + + pub fn is_error(&self) -> bool { + matches!( + self, + OfferResponse::ErrorNoIdentity + | OfferResponse::ErrorNoAccess + | OfferResponse::ErrorInvalidKey + | OfferResponse::ErrorThrottled + | OfferResponse::ErrorInvalidData + | OfferResponse::ErrorInvalidStamp + ) + } + + pub fn as_peer_error(&self) -> Option { + match self { + OfferResponse::ErrorNoIdentity => Some(PeerError::NoIdentity), + OfferResponse::ErrorNoAccess => Some(PeerError::NoAccess), + OfferResponse::ErrorInvalidKey => Some(PeerError::InvalidKey), + OfferResponse::ErrorThrottled => Some(PeerError::Throttled), + OfferResponse::ErrorInvalidData => Some(PeerError::InvalidData), + OfferResponse::ErrorInvalidStamp => Some(PeerError::InvalidStamp), + _ => None, + } + } +} + +#[derive(Debug)] +pub struct SyncSession { + pub peer_hash: [u8; 16], + pub state: SyncState, + pub offered_ids: Vec<[u8; 16]>, + pub wanted_ids: Vec<[u8; 16]>, + pub transferred: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + Idle, + OfferSent, + Receiving, + Sending, + Complete, + Failed, +} + +impl SyncSession { + pub fn new(peer_hash: [u8; 16]) -> Self { + Self { + peer_hash, + state: SyncState::Idle, + offered_ids: Vec::new(), + wanted_ids: Vec::new(), + transferred: 0, + } + } + + pub fn prepare_offer(&mut self, our_ids: Vec<[u8; 16]>, peering_key: Vec) -> SyncOffer { + self.offered_ids = our_ids.clone(); + self.state = SyncState::OfferSent; + SyncOffer { + peering_key, + transient_ids: our_ids.into_iter().map(|id| id.to_vec()).collect(), + } + } + + /// Process a received offer; returns a SyncGet for IDs we don't have. + pub fn process_offer(&mut self, offer: &SyncOffer, our_store: &PropagationStore) -> SyncGet { + let wanted: Vec> = offer + .transient_ids + .iter() + .filter(|id| { + if id.len() == 16 { + let mut arr = [0u8; 16]; + arr.copy_from_slice(id); + !our_store.contains(&arr) + } else { + false + } + }) + .cloned() + .collect(); + + self.state = SyncState::Receiving; + SyncGet { wanted_ids: wanted } + } + + pub fn process_get(&mut self, get: &SyncGet) { + self.wanted_ids = get + .wanted_ids + .iter() + .filter_map(|id| { + if id.len() == 16 { + let mut arr = [0u8; 16]; + arr.copy_from_slice(id); + Some(arr) + } else { + None + } + }) + .collect(); + self.state = SyncState::Sending; + } + + pub fn mark_complete(&mut self) { + self.state = SyncState::Complete; + } + + pub fn mark_failed(&mut self) { + self.state = SyncState::Failed; + } + + pub fn record_transfer(&mut self) { + self.transferred += 1; + } + + pub fn is_finished(&self) -> bool { + self.state == SyncState::Complete || self.state == SyncState::Failed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::propagation::{PropagationEntry, PropagationStore}; + + #[test] + fn test_sync_offer_pack_unpack() { + let offer = SyncOffer { + peering_key: vec![0xDD; 32], + transient_ids: vec![vec![0xAA; 16], vec![0xBB; 16], vec![0xCC; 16]], + }; + + let packed = offer.pack(); + assert!(!packed.is_empty()); + + let mut unpacked = SyncOffer::new(); + unpacked.unpack(&packed).unwrap(); + assert_eq!(unpacked.peering_key, vec![0xDD; 32]); + assert_eq!(unpacked.transient_ids.len(), 3); + assert_eq!(unpacked.transient_ids[0], vec![0xAA; 16]); + assert_eq!(unpacked.transient_ids[1], vec![0xBB; 16]); + assert_eq!(unpacked.transient_ids[2], vec![0xCC; 16]); + } + + #[test] + fn test_sync_get_pack_unpack() { + let get = SyncGet { + wanted_ids: vec![vec![0x11; 16], vec![0x22; 16]], + }; + + let packed = get.pack(); + assert!(!packed.is_empty()); + + let mut unpacked = SyncGet::new(); + unpacked.unpack(&packed).unwrap(); + assert_eq!(unpacked.wanted_ids.len(), 2); + assert_eq!(unpacked.wanted_ids[0], vec![0x11; 16]); + assert_eq!(unpacked.wanted_ids[1], vec![0x22; 16]); + } + + #[test] + fn test_sync_offer_msg_type() { + let offer = SyncOffer::new(); + assert_eq!(offer.msg_type(), SYNC_MSG_OFFER); + } + + #[test] + fn test_sync_get_msg_type() { + let get = SyncGet::new(); + assert_eq!(get.msg_type(), SYNC_MSG_GET); + } + + #[test] + fn test_sync_session_offer_flow() { + let peer_hash = [0xAA; 16]; + let mut session = SyncSession::new(peer_hash); + assert_eq!(session.state, SyncState::Idle); + + let ids = vec![[0x01; 16], [0x02; 16], [0x03; 16]]; + let offer = session.prepare_offer(ids.clone(), vec![0xFF; 32]); + assert_eq!(session.state, SyncState::OfferSent); + assert_eq!(offer.transient_ids.len(), 3); + assert_eq!(session.offered_ids.len(), 3); + } + + #[test] + fn test_sync_session_process_offer() { + let peer_hash = [0xBB; 16]; + let mut session = SyncSession::new(peer_hash); + + let mut store = PropagationStore::new(); + store.insert(PropagationEntry::new([0x01; 16], [0; 32], [0; 16], 100, 0)); + + let offer = SyncOffer { + peering_key: vec![0xFF; 32], + transient_ids: vec![vec![0x01; 16], vec![0x02; 16], vec![0x03; 16]], + }; + + let get = session.process_offer(&offer, &store); + assert_eq!(session.state, SyncState::Receiving); + assert_eq!(get.wanted_ids.len(), 2); + assert_eq!(get.wanted_ids[0], vec![0x02; 16]); + assert_eq!(get.wanted_ids[1], vec![0x03; 16]); + } + + #[test] + fn test_sync_session_process_get() { + let peer_hash = [0xCC; 16]; + let mut session = SyncSession::new(peer_hash); + session.state = SyncState::OfferSent; + + let get = SyncGet { + wanted_ids: vec![vec![0x01; 16], vec![0x02; 16]], + }; + + session.process_get(&get); + assert_eq!(session.state, SyncState::Sending); + assert_eq!(session.wanted_ids.len(), 2); + } + + #[test] + fn test_sync_session_complete() { + let mut session = SyncSession::new([0xDD; 16]); + session.state = SyncState::Sending; + assert!(!session.is_finished()); + + session.mark_complete(); + assert_eq!(session.state, SyncState::Complete); + assert!(session.is_finished()); + } + + #[test] + fn test_sync_session_failed() { + let mut session = SyncSession::new([0xEE; 16]); + session.state = SyncState::OfferSent; + + session.mark_failed(); + assert_eq!(session.state, SyncState::Failed); + assert!(session.is_finished()); + } + + #[test] + fn test_sync_session_transfer_tracking() { + let mut session = SyncSession::new([0xFF; 16]); + assert_eq!(session.transferred, 0); + + session.record_transfer(); + session.record_transfer(); + session.record_transfer(); + assert_eq!(session.transferred, 3); + } + + #[test] + fn test_sync_offer_empty() { + let offer = SyncOffer::new(); + assert!(offer.transient_ids.is_empty()); + + let packed = offer.pack(); + let mut unpacked = SyncOffer::new(); + unpacked.unpack(&packed).unwrap(); + assert!(unpacked.transient_ids.is_empty()); + } + + #[test] + fn test_sync_get_empty() { + let get = SyncGet::new(); + assert!(get.wanted_ids.is_empty()); + + let packed = get.pack(); + let mut unpacked = SyncGet::new(); + unpacked.unpack(&packed).unwrap(); + assert!(unpacked.wanted_ids.is_empty()); + } + + #[test] + fn test_sync_session_process_offer_empty_store() { + let mut session = SyncSession::new([0xAA; 16]); + let store = PropagationStore::new(); + + let offer = SyncOffer { + peering_key: vec![0xFF; 32], + transient_ids: vec![vec![0x01; 16], vec![0x02; 16]], + }; + + let get = session.process_offer(&offer, &store); + assert_eq!(get.wanted_ids.len(), 2); + } + + #[test] + fn test_sync_offer_invalid_length_ids() { + let mut session = SyncSession::new([0xBB; 16]); + let store = PropagationStore::new(); + + let offer = SyncOffer { + peering_key: vec![0xFF; 32], + transient_ids: vec![vec![0x01; 16], vec![0x02; 8], vec![0x03; 32]], + }; + + let get = session.process_offer(&offer, &store); + assert_eq!(get.wanted_ids.len(), 1); + } + + #[test] + fn test_sync_get_invalid_length_ids() { + let mut session = SyncSession::new([0xCC; 16]); + + let get = SyncGet { + wanted_ids: vec![vec![0x01; 16], vec![0x02; 10]], + }; + + session.process_get(&get); + assert_eq!(session.wanted_ids.len(), 1); + } + + #[test] + fn test_offer_response_true() { + // msgpack true = 0xC3 + let data = [0xC3]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::WantAll); + assert!(!resp.is_error()); + } + + #[test] + fn test_offer_response_false() { + // msgpack false = 0xC2 + let data = [0xC2]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::HaveAll); + assert!(!resp.is_error()); + } + + #[test] + fn test_offer_response_error_no_identity() { + // msgpack uint8 0xF0 = [0xCC, 0xF0] + let data = [0xCC, 0xF0]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::ErrorNoIdentity); + assert!(resp.is_error()); + assert_eq!(resp.as_peer_error(), Some(PeerError::NoIdentity)); + } + + #[test] + fn test_offer_response_error_no_access() { + let data = [0xCC, 0xF1]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::ErrorNoAccess); + assert!(resp.is_error()); + } + + #[test] + fn test_offer_response_error_invalid_key() { + let data = [0xCC, 0xF3]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::ErrorInvalidKey); + assert!(resp.is_error()); + } + + #[test] + fn test_offer_response_error_throttled() { + let data = [0xCC, 0xF6]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::ErrorThrottled); + assert!(resp.is_error()); + } + + #[test] + fn test_offer_response_want_some() { + let id1 = vec![0xAA; 16]; + let id2 = vec![0xBB; 16]; + let value = rmpv::Value::Array(vec![ + rmpv::Value::Binary(id1.clone()), + rmpv::Value::Binary(id2.clone()), + ]); + let resp = OfferResponse::from_value(&value); + match resp { + OfferResponse::WantSome(ids) => { + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], id1); + assert_eq!(ids[1], id2); + } + _ => panic!("expected WantSome"), + } + } + + #[test] + fn test_offer_response_nil() { + // msgpack nil = 0xC0 + let data = [0xC0]; + let resp = OfferResponse::from_msgpack(&data); + assert_eq!(resp, OfferResponse::Unknown); + } +} diff --git a/crates/lxmf-core/src/ticket.rs b/crates/lxmf-core/src/ticket.rs new file mode 100644 index 0000000..3d63b06 --- /dev/null +++ b/crates/lxmf-core/src/ticket.rs @@ -0,0 +1,161 @@ +//! LXMF Ticket system: bypass PoW with pre-shared 16-byte tokens. +//! +//! Trusted peers may exchange tickets that bypass stamp requirements for a +//! fixed expiry window. Tickets are single-use and renewable before expiry. + +use serde::{Deserialize, Serialize}; + +use crate::constants::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ticket { + pub token: [u8; 16], + pub destination_hash: [u8; 16], + /// Expiry timestamp (Unix epoch seconds). + pub expires: f64, + pub used: bool, +} + +impl Ticket { + pub fn new(token: [u8; 16], destination_hash: [u8; 16], expires: f64) -> Self { + Self { + token, + destination_hash, + expires, + used: false, + } + } + + pub fn is_valid(&self, now: f64) -> bool { + !self.used && now < self.expires + } + + pub fn should_renew(&self, now: f64) -> bool { + self.is_valid(now) && (self.expires - now) < TICKET_RENEW as f64 + } + + pub fn use_ticket(&mut self) { + self.used = true; + } +} + +#[derive(Debug, Default)] +pub struct TicketStore { + tickets: Vec, +} + +impl TicketStore { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, ticket: Ticket) { + self.tickets.push(ticket); + } + + pub fn find(&self, destination_hash: &[u8; 16], now: f64) -> Option<&Ticket> { + self.tickets + .iter() + .find(|t| &t.destination_hash == destination_hash && t.is_valid(now)) + } + + /// Find and mark a ticket as used. Returns the token on success. + pub fn use_for(&mut self, destination_hash: &[u8; 16], now: f64) -> Option<[u8; 16]> { + for ticket in &mut self.tickets { + if &ticket.destination_hash == destination_hash && ticket.is_valid(now) { + ticket.use_ticket(); + return Some(ticket.token); + } + } + None + } + + /// Drop expired and used tickets (past TICKET_GRACE). + pub fn cull(&mut self, now: f64) { + self.tickets + .retain(|t| !t.used && now < t.expires + TICKET_GRACE as f64); + } + + pub fn count_valid(&self, now: f64) -> usize { + self.tickets.iter().filter(|t| t.is_valid(now)).count() + } + + /// Snapshot of all stored tickets (including expired / used). + pub fn all(&self) -> &[Ticket] { + &self.tickets + } + + /// Replace the entire ticket set — used when restoring from persisted state. + pub fn replace_all(&mut self, tickets: Vec) { + self.tickets = tickets; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ticket_validity() { + let ticket = Ticket::new([0xAA; 16], [0xBB; 16], 1000.0); + assert!(ticket.is_valid(999.0)); + assert!(!ticket.is_valid(1001.0)); + } + + #[test] + fn test_ticket_used() { + let mut ticket = Ticket::new([0xAA; 16], [0xBB; 16], 1000.0); + assert!(ticket.is_valid(500.0)); + ticket.use_ticket(); + assert!(!ticket.is_valid(500.0)); + } + + #[test] + fn test_ticket_renew() { + let expires = 2_000_000.0; + let ticket = Ticket::new([0xAA; 16], [0xBB; 16], expires); + // TICKET_RENEW is ~1 week; close to expiry should trigger, far should not. + assert!(ticket.should_renew(expires - 1.0)); + assert!(!ticket.should_renew(0.0)); + } + + #[test] + fn test_ticket_store() { + let mut store = TicketStore::new(); + let dest = [0xBB; 16]; + + store.add(Ticket::new([0x01; 16], dest, 1000.0)); + store.add(Ticket::new([0x02; 16], dest, 2000.0)); + store.add(Ticket::new([0x03; 16], [0xCC; 16], 1500.0)); + + assert_eq!(store.count_valid(500.0), 3); + assert_eq!(store.count_valid(1500.0), 1); + + let found = store.find(&dest, 500.0); + assert!(found.is_some()); + } + + #[test] + fn test_ticket_store_use() { + let mut store = TicketStore::new(); + let dest = [0xBB; 16]; + + store.add(Ticket::new([0x01; 16], dest, 1000.0)); + + let token = store.use_for(&dest, 500.0); + assert_eq!(token, Some([0x01; 16])); + + let token = store.use_for(&dest, 500.0); + assert!(token.is_none()); + } + + #[test] + fn test_ticket_store_cull() { + let mut store = TicketStore::new(); + store.add(Ticket::new([0x01; 16], [0xBB; 16], 100.0)); + store.add(Ticket::new([0x02; 16], [0xBB; 16], 99999.0)); + + store.cull(100.0 + TICKET_GRACE as f64 + 1.0); + assert_eq!(store.count_valid(99999.0 - 1.0), 1); + } +} diff --git a/crates/lxmf-tools/Cargo.toml b/crates/lxmf-tools/Cargo.toml new file mode 100644 index 0000000..16eb094 --- /dev/null +++ b/crates/lxmf-tools/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "lxmf-tools" +description = "Rust LXMF daemon and CLI tools for Reticulum" +version.workspace = true +edition.workspace = true +license.workspace = true +autobins = false + +[dependencies] +lxmf-core = { workspace = true } +rns-runtime = { workspace = true, features = ["serial"] } +rns-identity = { workspace = true } +rns-transport = { workspace = true } +rns-wire = { workspace = true } +rns-crypto = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +bytes = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +hex = { workspace = true } +base64 = { workspace = true } +serde_json = { workspace = true } +rmpv = { workspace = true } + +[[bin]] +name = "lxmd-rs" +path = "src/bin/lxmd-rs.rs" diff --git a/crates/lxmf-tools/src/bin/lxmd-rs.rs b/crates/lxmf-tools/src/bin/lxmd-rs.rs new file mode 100644 index 0000000..47dd97c --- /dev/null +++ b/crates/lxmf-tools/src/bin/lxmd-rs.rs @@ -0,0 +1,6 @@ +#[path = "../commands/lxmd.rs"] +mod lxmd; + +fn main() { + lxmd::main() +} diff --git a/crates/lxmf-tools/src/commands/lxmd.rs b/crates/lxmf-tools/src/commands/lxmd.rs new file mode 100644 index 0000000..648eba7 --- /dev/null +++ b/crates/lxmf-tools/src/commands/lxmd.rs @@ -0,0 +1,2311 @@ +//! LXMF Daemon (lxmd) -- propagation node and message handler. +//! +//! Python reference: LXMF/Utilities/lxmd.py. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use bytes::Bytes; +use clap::Parser; +use tokio::sync::mpsc; + +use std::sync::{Arc, Mutex}; + +use lxmf_core::message::LxMessage; +use lxmf_core::propagation_node::{PropagationNode, PropagationNodeConfig}; +use lxmf_core::router::{LxmRouter, OutboundAction}; +use lxmf_tools::daemon::{DaemonConfig, create_router_with_transport, execute_on_inbound}; +use lxmf_tools::lxmd_cli::{ + Args, example_config, load_hash_list, normalize_hash_hex, parse_destination_hash, + parse_send_fields_json, +}; +use lxmf_tools::lxmd_control::{ + CONTROL_APP_NAME, ControlCommandKind, ControlResponse, decode_control_response, + encode_control_success, encode_nil_response, encode_peer_error, encode_router_control_stats, + exit_for_control_response, format_remote_status, print_control_link_error, query_control, + resolve_remote_identity_hash, +}; +use lxmf_tools::lxmd_runtime::{ + LxmdPaths, delivery_announce_app_data, preflight_control_command, + propagation_announce_app_data, resolve_config_dirs, +}; +use rns_identity::announce::AnnounceData; +use rns_identity::destination::Destination; +use rns_identity::identity::Identity; +use rns_identity::ratchet::{ + RatchetRing, ReceivedRatchet, clean_received_ratchets_dir, purge_expired_ratchets_in_memory, +}; +use rns_transport::messages::{AnnounceHandlerEvent, TransportMessage}; + +const LXMF_APP_NAME: &str = "lxmf.delivery"; + +#[derive(Debug, Clone, Default)] +struct ControlSnapshot { + allowed_control: Vec<[u8; 16]>, + peer_hashes: HashSet<[u8; 16]>, + stats_response: Option>, +} + +#[derive(Debug, Clone, Copy)] +enum ControlCommand { + Sync([u8; 16]), + Unpeer([u8; 16]), +} + +fn setup_logging(verbose: u8, quiet: u8, service: bool) { + let level = match (verbose, quiet) { + (v, _) if v >= 3 => tracing::Level::TRACE, + (2, _) => tracing::Level::DEBUG, + (1, _) => tracing::Level::INFO, + (0, 0) => { + if service { + tracing::Level::WARN + } else { + tracing::Level::INFO + } + } + (_, q) if q >= 2 => tracing::Level::ERROR, + (_, 1) => tracing::Level::WARN, + _ => tracing::Level::INFO, + }; + + tracing_subscriber::fmt() + .with_max_level(level) + .with_target(false) + .init(); +} + +fn now_f64() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +fn create_control_announce_packet( + identity: &Identity, + control_dest_hash: [u8; 16], +) -> Result, String> { + let announce = AnnounceData::create(identity, CONTROL_APP_NAME, None, None) + .map_err(|e| format!("Failed to create control announce: {e}"))?; + let payload = announce.pack(); + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Announce, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: control_dest_hash, + context: rns_wire::context::PacketContext::None, + }; + + let mut raw = header.pack(); + raw.extend_from_slice(&payload); + Ok(raw) +} + +fn send_control_announce_try( + tx: &mpsc::Sender, + identity: &Identity, + control_dest_hash: [u8; 16], +) { + match create_control_announce_packet(identity, control_dest_hash) { + Ok(raw) => { + let _ = tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: control_dest_hash, + }, + )); + } + Err(e) => tracing::warn!("{e}"), + } +} + +fn create_propagation_announce_packet_for( + identity: &Identity, + propagation_dest_hash: [u8; 16], + config: &DaemonConfig, + ratchet_ref: Option<&[u8; 32]>, +) -> Result, String> { + let mut pn_data = lxmf_core::handlers::PropagationNodeAnnounceData::new( + config.propagation_enabled && !config.from_static_only, + config.propagation_limit_kb as u64, + config.sync_limit_kb as u64, + config.propagation_stamp_cost, + config.propagation_stamp_flex, + config.peering_cost, + ); + if let Some(ref name) = config.node_name { + pn_data.set_name(name); + } + let app_data = propagation_announce_app_data(&pn_data); + + let announce = AnnounceData::create( + identity, + "lxmf.propagation", + Some(app_data.as_slice()), + ratchet_ref, + ) + .map_err(|e| format!("Failed to create propagation announce: {e}"))?; + + let payload = announce.pack(); + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: ratchet_ref.is_some(), + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Announce, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: propagation_dest_hash, + context: rns_wire::context::PacketContext::None, + }; + + let mut raw = header.pack(); + raw.extend_from_slice(&payload); + Ok(raw) +} + +fn send_propagation_announce_try( + tx: &mpsc::Sender, + identity: &Identity, + propagation_dest_hash: [u8; 16], + config: &DaemonConfig, +) { + match create_propagation_announce_packet_for(identity, propagation_dest_hash, config, None) { + Ok(raw) => { + let _ = tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: propagation_dest_hash, + }, + )); + } + Err(e) => tracing::warn!("{e}"), + } +} + +/// Owns identity, router, and crypto state; drives the daemon main loop. +// Several fields are long-lived state handles that are intentionally retained +// even when the runner only touches them through setup or shutdown paths. +#[allow(dead_code)] +struct LxmdRunner { + identity: Identity, + identity_hash: String, + lxmf_dest_hash: [u8; 16], + propagation_dest_hash: [u8; 16], + control_dest_hash: [u8; 16], + router: LxmRouter, + config: DaemonConfig, + data_dir: PathBuf, + messages_dir: PathBuf, + ratchets_dir: PathBuf, + ratchet_ring: RatchetRing, + received_ratchets: HashMap, + known_identities: HashMap, + link_delivery: Option, + link_delivery_failures: Vec, + propagation_sync: Option, + propagation_client: Option, + propagation_node: Option>>, + transport_tx: mpsc::Sender, + /// Plaintext application data decoded by the LinkManager. + link_packet_rx: mpsc::Receiver<(Vec, [u8; 16])>, + /// Completed resource transfers from the LinkManager. + resource_rx: mpsc::Receiver<(Vec, [u8; 16])>, + /// Plaintext propagation-wrapper packets decoded by the propagation LinkManager. + prop_link_packet_rx: mpsc::Receiver<(Vec, [u8; 16])>, + /// Completed propagation-wrapper resources from the propagation LinkManager. + prop_resource_rx: mpsc::Receiver<(Vec, [u8; 16])>, + /// Non-link inbound packets; still encrypted, need destination-level decrypt. + inbound_raw_rx: mpsc::Receiver>, + announce_rx: mpsc::Receiver, + last_peer_announce: f64, + last_node_announce: f64, + last_propagation_check: f64, + last_crypto_save: f64, + last_cull: f64, + last_ratchet_clean: f64, + received_ratchets_dir: PathBuf, + control_state: Arc>, + control_command_rx: mpsc::Receiver, +} + +impl LxmdRunner { + fn new( + config: DaemonConfig, + config_dir: &Path, + transport_tx: mpsc::Sender, + ) -> Result> { + let paths = LxmdPaths::new(config_dir); + std::fs::create_dir_all(&paths.config_dir)?; + + let identity_path = paths.preferred_identity_path().to_path_buf(); + let identity = if identity_path.exists() { + tracing::info!("Loading identity from {}", identity_path.display()); + Identity::from_file(&identity_path)? + } else { + tracing::info!("No identity found, generating new one"); + let id = Identity::new(); + id.to_file(&paths.identity_path)?; + id + }; + + let identity_hash = hex::encode(identity.hash); + + let lxmf_dest_hash = + Destination::hash_from_name_and_identity(LXMF_APP_NAME, Some(&identity.hash)); + let propagation_dest_hash = + Destination::hash_from_name_and_identity("lxmf.propagation", Some(&identity.hash)); + let control_dest_hash = + Destination::hash_from_name_and_identity(CONTROL_APP_NAME, Some(&identity.hash)); + + tracing::info!( + "Identity: {} (LXMF: {})", + &identity_hash[..16], + &hex::encode(lxmf_dest_hash)[..16], + ); + + let ratchet_dir = paths.ratchets_dir.clone(); + std::fs::create_dir_all(&ratchet_dir)?; + let ring_path = paths.ratchet_ring_path.clone(); + let mut ratchet_ring = if ring_path.exists() { + RatchetRing::load(&ring_path) + .map(|(ring, _sig)| ring) + .unwrap_or_else(|e| { + tracing::warn!("Failed to load ratchet ring: {e}, creating new"); + RatchetRing::new() + }) + } else { + RatchetRing::new() + }; + if ratchet_ring.is_empty() { + ratchet_ring.rotate(); + let sig = identity + .sign( + ratchet_ring + .current_public_key() + .unwrap_or([0u8; 32]) + .as_ref(), + ) + .unwrap_or([0u8; 64]); + let _ = ratchet_ring.save(&ring_path, &sig); + } + + // Mirrors Python `Identity._clean_ratchets()`: sweep the directory at + // startup so stale entries don't survive a restart. + let received_dir = paths.received_ratchets_dir.clone(); + std::fs::create_dir_all(&received_dir)?; + let removed = clean_received_ratchets_dir(&received_dir); + if removed > 0 { + tracing::info!(removed, "swept expired received-ratchet files at startup"); + } + let mut received_ratchets = HashMap::new(); + if let Ok(entries) = std::fs::read_dir(&received_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_stem().and_then(|n| n.to_str()) + && let Ok(rr) = ReceivedRatchet::load(&path) + { + received_ratchets.insert(name.to_string(), rr); + } + } + } + + // known_identities format: concat of [dest_hash:16][pubkey:64] + let ki_path = paths.known_identities_path.clone(); + let mut known_identities: HashMap = HashMap::new(); + if ki_path.exists() + && let Ok(data) = std::fs::read(&ki_path) + { + let mut pos = 0; + while pos + 80 <= data.len() { + let mut dh = [0u8; 16]; + dh.copy_from_slice(&data[pos..pos + 16]); + let mut pk = [0u8; 64]; + pk.copy_from_slice(&data[pos + 16..pos + 80]); + known_identities.insert(hex::encode(dh), pk); + pos += 80; + } + } + + tracing::info!( + ratchet_keys = ratchet_ring.len(), + received_ratchets = received_ratchets.len(), + known_identities = known_identities.len(), + "Crypto state loaded" + ); + + let router = create_router_with_transport(&config, transport_tx.clone()); + + // LinkManager handles link handshakes (ECDH), keepalive, identification, + // and resource transfers; it forwards plaintext application data here. + let (delivery_tx, delivery_rx) = mpsc::channel(256); + let (link_packet_tx, link_packet_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + let (resource_tx, resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + let (prop_link_packet_tx, prop_link_packet_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + let (prop_resource_tx, prop_resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); + let (inbound_raw_tx, inbound_raw_rx) = mpsc::channel::>(256); + + let signing_key = identity.get_signing_key(); + let mut link_mgr = rns_runtime::link_manager::LinkManager::with_destination( + transport_tx.clone(), + delivery_rx, + &identity, + LXMF_APP_NAME, + signing_key + .unwrap_or_else(|| panic!("Identity must have signing key for link management")), + ); + link_mgr.set_link_packet_channel(link_packet_tx); + link_mgr.set_resource_completed_channel(resource_tx); + link_mgr.set_inbound_raw_channel(inbound_raw_tx); + + let _ = transport_tx.try_send(TransportMessage::RegisterDestination { + hash: lxmf_dest_hash, + app_name: LXMF_APP_NAME.to_string(), + delivery_tx: Some(delivery_tx), + }); + + // Spawn the LinkManager as a background task + tokio::spawn(async move { + link_mgr.run().await; + }); + + let control_state = Arc::new(Mutex::new(ControlSnapshot { + allowed_control: vec![identity.hash], + peer_hashes: HashSet::new(), + stats_response: None, + })); + let (control_command_tx, control_command_rx) = mpsc::channel::(256); + + let propagation_node: Option>> = if config.propagation_enabled { + let (prop_delivery_tx, prop_delivery_rx) = mpsc::channel(256); + let _ = transport_tx.try_send(TransportMessage::RegisterDestination { + hash: propagation_dest_hash, + app_name: "lxmf.propagation".to_string(), + delivery_tx: Some(prop_delivery_tx), + }); + + let pn_config = PropagationNodeConfig { + max_storage: config + .message_storage_limit + .unwrap_or(config.propagation_limit_kb * 1024), + max_message_size: config.propagation_limit_kb * 1024, + max_message_age: lxmf_core::constants::MESSAGE_EXPIRY, + min_stamp_cost: config.propagation_stamp_cost, + ..Default::default() + }; + let prop_storage_path = paths.propagation_store_dir.clone(); + let pn = match PropagationNode::with_storage( + pn_config, + propagation_dest_hash, + prop_storage_path, + ) { + Ok(node) => Arc::new(Mutex::new(node)), + Err(e) => { + tracing::warn!("Propagation disk storage failed, using in-memory: {e}"); + Arc::new(Mutex::new(PropagationNode::new( + PropagationNodeConfig { + max_storage: config + .message_storage_limit + .unwrap_or(config.propagation_limit_kb * 1024), + max_message_size: config.propagation_limit_kb * 1024, + max_message_age: lxmf_core::constants::MESSAGE_EXPIRY, + min_stamp_cost: config.propagation_stamp_cost, + ..Default::default() + }, + propagation_dest_hash, + ))) + } + }; + + let prop_signing_key = identity.get_signing_key().unwrap_or_else(|| { + panic!("Identity must have signing key for propagation link management") + }); + let mut prop_link_mgr = rns_runtime::link_manager::LinkManager::with_destination( + transport_tx.clone(), + prop_delivery_rx, + &identity, + "lxmf.propagation", + prop_signing_key, + ); + prop_link_mgr.set_link_packet_channel(prop_link_packet_tx); + prop_link_mgr.set_resource_completed_channel(prop_resource_tx); + + let pn_for_handler = pn.clone(); + let offer_path_hash = rns_crypto::sha::truncated_hash( + lxmf_core::constants::OFFER_REQUEST_PATH.as_bytes(), + ); + let get_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::MESSAGE_GET_PATH.as_bytes()); + let link_identities = prop_link_mgr.link_identities_handle(); + let local_identity_hash = identity.hash; + prop_link_mgr.set_request_handler(move |link_id, path_hash, data| { + let mut node = pn_for_handler.lock().ok()?; + let remote_identity_hash = link_identities + .lock() + .ok() + .and_then(|ids| ids.get(&link_id).copied()); + let remote_identity_ref = remote_identity_hash.as_ref(); + let client_dest_hash = remote_identity_hash + .map(|identity_hash| { + Destination::hash_from_name_and_identity( + LXMF_APP_NAME, + Some(&identity_hash), + ) + }) + .unwrap_or([0; 16]); + let handler = + lxmf_core::handlers::PropagationRequestHandler::new(local_identity_hash); + if path_hash == offer_path_hash { + tracing::info!("propagation: handling offer request"); + Some(handler.handle_offer_request(remote_identity_ref, &data, &mut node)) + } else if path_hash == get_path_hash { + tracing::info!("propagation: handling get request"); + Some(handler.handle_message_get_request( + remote_identity_ref, + &client_dest_hash, + &data, + &mut node, + )) + } else { + tracing::debug!( + path = hex::encode(path_hash), + "propagation: unknown request path" + ); + None + } + }); + + let prop_announce_tx = transport_tx.clone(); + let prop_announce_identity = identity + .get_private_key() + .and_then(|key| Identity::from_private_key(&*key).ok()); + let prop_announce_config = config.clone(); + prop_link_mgr.set_announce_handler(move || { + if let Some(ref identity) = prop_announce_identity { + send_propagation_announce_try( + &prop_announce_tx, + identity, + propagation_dest_hash, + &prop_announce_config, + ); + } + }); + + tokio::spawn(async move { + prop_link_mgr.run().await; + }); + + let (control_delivery_tx, control_delivery_rx) = mpsc::channel(256); + let _ = transport_tx.try_send(TransportMessage::RegisterDestination { + hash: control_dest_hash, + app_name: CONTROL_APP_NAME.to_string(), + delivery_tx: Some(control_delivery_tx), + }); + + let control_signing_key = identity.get_signing_key().unwrap_or_else(|| { + panic!("Identity must have signing key for control link management") + }); + let mut control_link_mgr = rns_runtime::link_manager::LinkManager::with_destination( + transport_tx.clone(), + control_delivery_rx, + &identity, + CONTROL_APP_NAME, + control_signing_key, + ); + let control_link_identities = control_link_mgr.link_identities_handle(); + let stats_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::STATS_GET_PATH.as_bytes()); + let sync_path_hash = + rns_crypto::sha::truncated_hash(lxmf_core::constants::SYNC_REQUEST_PATH.as_bytes()); + let unpeer_path_hash = rns_crypto::sha::truncated_hash( + lxmf_core::constants::UNPEER_REQUEST_PATH.as_bytes(), + ); + let control_state_for_handler = control_state.clone(); + let command_tx_for_handler = control_command_tx.clone(); + control_link_mgr.set_request_handler(move |link_id, path_hash, data| { + let remote_identity_hash = control_link_identities + .lock() + .ok() + .and_then(|ids| ids.get(&link_id).copied()); + let snapshot = control_state_for_handler + .lock() + .map(|state| state.clone()) + .unwrap_or_default(); + + let Some(remote_hash) = remote_identity_hash else { + return Some(encode_peer_error( + lxmf_core::constants::PeerError::NoIdentity, + )); + }; + if !snapshot.allowed_control.contains(&remote_hash) { + return Some(encode_peer_error(lxmf_core::constants::PeerError::NoAccess)); + } + + if path_hash == stats_path_hash { + tracing::info!("control: handling stats request"); + Some(snapshot.stats_response.unwrap_or_else(encode_nil_response)) + } else if path_hash == sync_path_hash { + tracing::info!("control: handling peer sync request"); + if data.len() != 16 { + return Some(encode_peer_error( + lxmf_core::constants::PeerError::InvalidData, + )); + } + let mut peer_hash = [0u8; 16]; + peer_hash.copy_from_slice(&data); + if !snapshot.peer_hashes.contains(&peer_hash) { + return Some(encode_peer_error(lxmf_core::constants::PeerError::NotFound)); + } + let _ = command_tx_for_handler.try_send(ControlCommand::Sync(peer_hash)); + Some(encode_control_success()) + } else if path_hash == unpeer_path_hash { + tracing::info!("control: handling unpeer request"); + if data.len() != 16 { + return Some(encode_peer_error( + lxmf_core::constants::PeerError::InvalidData, + )); + } + let mut peer_hash = [0u8; 16]; + peer_hash.copy_from_slice(&data); + if !snapshot.peer_hashes.contains(&peer_hash) { + return Some(encode_peer_error(lxmf_core::constants::PeerError::NotFound)); + } + let _ = command_tx_for_handler.try_send(ControlCommand::Unpeer(peer_hash)); + Some(encode_control_success()) + } else { + tracing::debug!( + path = hex::encode(path_hash), + "control: unknown request path" + ); + None + } + }); + + let control_announce_tx = transport_tx.clone(); + let control_announce_identity = identity + .get_private_key() + .and_then(|key| Identity::from_private_key(&*key).ok()); + control_link_mgr.set_announce_handler(move || { + if let Some(ref identity) = control_announce_identity { + send_control_announce_try(&control_announce_tx, identity, control_dest_hash); + } + }); + + tokio::spawn(async move { + control_link_mgr.run().await; + }); + + tracing::info!("propagation sync server ready for offer/get requests"); + Some(pn) + } else { + None + }; + + let (announce_tx, announce_rx) = mpsc::channel(256); + let _ = transport_tx.try_send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(LXMF_APP_NAME.to_string()), + receive_path_responses: true, + callback_tx: announce_tx.clone(), + }); + let _ = transport_tx.try_send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some("lxmf.propagation".to_string()), + receive_path_responses: true, + callback_tx: announce_tx, + }); + + let messages_dir = paths.messages_dir.clone(); + std::fs::create_dir_all(&messages_dir)?; + + let now = now_f64(); + + let mut runner = Self { + identity, + identity_hash, + lxmf_dest_hash, + propagation_dest_hash, + control_dest_hash, + router, + config, + data_dir: paths.router_state_dir, + messages_dir, + ratchets_dir: paths.ratchets_dir, + ratchet_ring, + received_ratchets, + known_identities, + link_delivery: None, + link_delivery_failures: Vec::new(), + propagation_sync: None, + propagation_client: None, + propagation_node: None, + transport_tx: transport_tx.clone(), + link_packet_rx, + resource_rx, + prop_link_packet_rx, + prop_resource_rx, + inbound_raw_rx, + announce_rx, + last_peer_announce: 0.0, + last_node_announce: 0.0, + last_propagation_check: 0.0, + last_crypto_save: now, + last_cull: now, + last_ratchet_clean: now, + received_ratchets_dir: received_dir, + control_state, + control_command_rx, + }; + + if runner.config.propagation_enabled { + let sync = lxmf_core::propagation_sync::PropagationSyncTask::new( + transport_tx.clone(), + runner.lxmf_dest_hash, + ); + runner.propagation_sync = Some(sync); + runner.propagation_node = propagation_node; + + tracing::info!("propagation sync server initialized"); + } + + if runner.config.propagation_enabled || runner.config.outbound_propagation_node.is_some() { + let mut client = lxmf_core::propagation_client::PropagationClient::new( + transport_tx.clone(), + Some(runner.identity.get_public_key()), + runner.identity.get_signing_key(), + ); + if let Some(ref node_hex) = runner.config.outbound_propagation_node { + match hex::decode(node_hex) { + Ok(bytes) if bytes.len() == 16 => { + let mut node = [0u8; 16]; + node.copy_from_slice(&bytes); + client.set_propagation_node(node); + runner.router.outbound_propagation_node = Some(node); + runner + .router + .peers + .entry(node) + .or_insert_with(|| lxmf_core::peer::LxmPeer::new(node)); + if !runner.router.static_peers.contains(&node) { + runner.router.static_peers.push(node); + } + tracing::info!( + node = %hex::encode(node), + "outbound propagation node configured" + ); + } + _ => { + tracing::warn!( + node = %node_hex, + "ignoring invalid outbound propagation node hash" + ); + } + } + } + runner.propagation_client = Some(client); + + tracing::info!("propagation client initialized"); + } + + Ok(runner) + } + + fn apply_config(&mut self) { + if self.config.propagation_enabled { + self.router.set_propagation_enabled(true); + if self.router.propagation_start_time.is_none() { + self.router.propagation_start_time = Some(now_f64()); + } + self.router.set_autopeer(self.config.autopeer); + self.router.set_max_peers(self.config.max_peers); + self.router + .set_propagation_limit(self.config.propagation_limit_kb); + self.router.set_stamp_requirements( + self.config.propagation_stamp_cost, + self.config.propagation_stamp_flex, + ); + } + + self.router + .set_message_storage_limit(self.config.message_storage_limit); + self.router.set_authentication(self.config.auth_required); + + if let Some(cost) = self.config.stamp_cost { + self.router.set_stamp_cost(self.lxmf_dest_hash, cost); + } + + if self.config.enforce_ratchets { + self.router.set_enforce_ratchets(true); + } + if self.config.enforce_stamps { + self.router.set_enforce_stamps(true); + } + + for configured in &self.config.control_allowed { + match parse_destination_hash(configured) { + Ok(hash) => self.router.allow_control(hash), + Err(e) => { + tracing::warn!(hash = %configured, "ignoring invalid control_allowed hash: {e}") + } + } + } + for configured in &self.config.static_peers { + match parse_destination_hash(configured) { + Ok(hash) => { + if !self.router.static_peers.contains(&hash) { + self.router.static_peers.push(hash); + } + self.router + .peers + .entry(hash) + .or_insert_with(|| lxmf_core::peer::LxmPeer::new(hash)); + } + Err(e) => { + tracing::warn!(hash = %configured, "ignoring invalid static peer hash: {e}") + } + } + } + for configured in &self.config.prioritise_destinations { + match parse_destination_hash(configured) { + Ok(hash) => self.router.prioritise(hash, 1), + Err(e) => { + tracing::warn!(hash = %configured, "ignoring invalid prioritised destination hash: {e}") + } + } + } + } + + fn refresh_control_state(&mut self) { + let mut allowed_control = vec![self.identity.hash]; + for hash in &self.router.allowed_control { + if !allowed_control.contains(hash) { + allowed_control.push(*hash); + } + } + + let peer_hashes = self.router.peers.keys().copied().collect::>(); + let stats_response = if self.config.propagation_enabled { + let node_guard = self + .propagation_node + .as_ref() + .and_then(|node| node.lock().ok()); + Some(encode_router_control_stats( + &self.router, + self.identity.hash, + self.propagation_dest_hash, + node_guard.as_deref(), + now_f64(), + )) + } else { + None + }; + + if let Ok(mut state) = self.control_state.lock() { + *state = ControlSnapshot { + allowed_control, + peer_hashes, + stats_response, + }; + } + } + + fn create_announce_packet(&mut self) -> Result, String> { + if self.ratchet_ring.needs_rotation() { + self.ratchet_ring.rotate(); + self.save_crypto_state(); + } + + let ratchet_pub = self.ratchet_ring.current_public_key(); + let ratchet_ref = ratchet_pub.as_ref(); + + let app_data = + delivery_announce_app_data(self.config.display_name.as_deref(), self.config.stamp_cost); + + let announce = AnnounceData::create( + &self.identity, + LXMF_APP_NAME, + Some(app_data.as_slice()), + ratchet_ref, + ) + .map_err(|e| format!("Failed to create announce: {e}"))?; + + let payload = announce.pack(); + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: ratchet_ref.is_some(), + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Announce, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: self.lxmf_dest_hash, + context: rns_wire::context::PacketContext::None, + }; + + let mut raw = header.pack(); + raw.extend_from_slice(&payload); + Ok(raw) + } + + fn create_propagation_announce_packet(&mut self) -> Result, String> { + if self.ratchet_ring.needs_rotation() { + self.ratchet_ring.rotate(); + self.save_crypto_state(); + } + + let ratchet_pub = self.ratchet_ring.current_public_key(); + let ratchet_ref = ratchet_pub.as_ref(); + + create_propagation_announce_packet_for( + &self.identity, + self.propagation_dest_hash, + &self.config, + ratchet_ref, + ) + } + + async fn send_announce(&mut self) -> Result<(), String> { + let raw = self.create_announce_packet()?; + self.transport_tx + .send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: self.lxmf_dest_hash, + }, + )) + .await + .map_err(|e| format!("Failed to send announce: {e}")) + } + + async fn send_propagation_announce(&mut self) -> Result<(), String> { + let raw = self.create_propagation_announce_packet()?; + self.transport_tx + .send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: self.propagation_dest_hash, + }, + )) + .await + .map_err(|e| format!("Failed to send propagation announce: {e}")) + } + + async fn send_control_announce(&mut self) -> Result<(), String> { + let raw = create_control_announce_packet(&self.identity, self.control_dest_hash)?; + self.transport_tx + .send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: self.control_dest_hash, + }, + )) + .await + .map_err(|e| format!("Failed to send control announce: {e}")) + } + + fn should_announce_control(&self) -> bool { + if !self.config.propagation_enabled { + return false; + } + let mut allowed = HashSet::from([self.identity.hash]); + allowed.extend(self.router.allowed_control.iter().copied()); + allowed.len() > 1 + } + + fn drain_control_commands(&mut self) { + while let Ok(command) = self.control_command_rx.try_recv() { + match command { + ControlCommand::Sync(peer_hash) => { + if !self.router.peers.contains_key(&peer_hash) { + continue; + } + if let Some(ref mut sync) = self.propagation_sync { + sync.request_sync_now(peer_hash); + } + if let Some(peer) = self.router.peers.get_mut(&peer_hash) { + peer.next_sync_attempt = 0.0; + peer.alive = true; + } + tracing::info!(peer = %hex::encode(peer_hash), "control: queued peer sync"); + } + ControlCommand::Unpeer(peer_hash) => { + self.router.unpeer(&peer_hash); + if let Err(e) = self.router.save_state(&self.data_dir) { + tracing::warn!("Failed to save router state after control unpeer: {e}"); + } + tracing::info!(peer = %hex::encode(peer_hash), "control: unpeered peer"); + } + } + } + } + + fn tick(&mut self) { + let now = now_f64(); + + self.drain_control_commands(); + + self.router.process_deferred_stamps(); + let actions = self.router.process_outbound(); + if !actions.is_empty() { + self.execute_encrypted_actions(actions); + } + + if let Some(ref mut ld) = self.link_delivery { + ld.drain_events(&self.known_identities); + let results = ld.tick(); + for result in results { + match result { + lxmf_core::link_delivery::DeliveryResult::Complete { msg_hash, .. } => { + if let Some(hash) = msg_hash { + tracing::info!(hash = %hex::encode(hash), "link delivery complete"); + } + } + lxmf_core::link_delivery::DeliveryResult::Failed { + msg_hash, reason, .. + } => { + tracing::warn!(reason = %reason, "link delivery failed"); + if let Some(hash) = msg_hash { + tracing::warn!(hash = %hex::encode(hash), "message delivery failed"); + } + self.link_delivery_failures.push(reason); + } + } + } + } + + if let Some(ref mut ps) = self.propagation_sync { + ps.drain_events(&self.known_identities); + ps.tick(); + } + + // Drive propagation client (download from node) + let mut downloaded_messages = Vec::new(); + let propagation_node_ready = self + .router + .outbound_propagation_node + .map(|node| self.known_identities.contains_key(&hex::encode(node))) + .unwrap_or(false); + if let Some(ref mut client) = self.propagation_client { + client.drain_events(&self.known_identities); + client.tick(); + + downloaded_messages = client.take_received_messages(); + + // Auto-download every 90s + if now - self.last_propagation_check > 90.0 + && client.state == lxmf_core::propagation_client::PropagationClientState::Idle + { + if propagation_node_ready { + client.start_download(); + self.last_propagation_check = now; + tracing::debug!("auto-triggered propagation download"); + } else if let Some(node) = self.router.outbound_propagation_node { + let _ = self.transport_tx.try_send(TransportMessage::RequestPath { + destination_hash: node, + }); + tracing::debug!( + node = %hex::encode(node), + "propagation node identity unknown; requesting path before download" + ); + } + } + } + // Borrow is released; process downloaded messages. + for msg_data in downloaded_messages { + self.handle_propagation_downloaded_data(&msg_data); + } + + if let Some(interval) = self.config.announce_interval + && now - self.last_peer_announce > interval as f64 + { + let tx = self.transport_tx.clone(); + if let Ok(raw) = self.create_announce_packet() { + let dest = self.lxmf_dest_hash; + let _ = tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: dest, + }, + )); + self.last_peer_announce = now; + tracing::debug!("periodic peer announce sent"); + } + } + + if self.config.propagation_enabled + && let Some(interval) = self.config.node_announce_interval + && now - self.last_node_announce > interval as f64 + && let Ok(raw) = self.create_propagation_announce_packet() + { + let dest = self.propagation_dest_hash; + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: dest, + }, + )); + if self.should_announce_control() + && let Ok(raw) = + create_control_announce_packet(&self.identity, self.control_dest_hash) + { + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw), + destination_hash: self.control_dest_hash, + }, + )); + } + self.last_node_announce = now; + tracing::debug!("periodic propagation node announce sent"); + } + + if now - self.last_cull > 300.0 { + self.router.cull_stamp_costs(); + self.router.cull_propagation(); + self.router.rotate_peers(); + self.last_cull = now; + } + + if now - self.last_crypto_save > 300.0 { + self.save_crypto_state(); + if let Err(e) = self.router.save_state(&self.data_dir) { + tracing::warn!("Failed to save router state: {e}"); + } + self.last_crypto_save = now; + } + + // 15-minute interval matches Python's CLEAN_INTERVAL. + if now - self.last_ratchet_clean > 900.0 { + let mem_dropped = purge_expired_ratchets_in_memory(&mut self.received_ratchets); + let disk_dropped = clean_received_ratchets_dir(&self.received_ratchets_dir); + if mem_dropped > 0 || disk_dropped > 0 { + tracing::debug!( + mem_dropped, + disk_dropped, + "ratchet cleanup pass: removed expired entries" + ); + } + self.last_ratchet_clean = now; + } + + self.refresh_control_state(); + } + + fn drain_announce_events(&mut self) -> Vec<[u8; 16]> { + let mut seen = Vec::new(); + while let Ok(event) = self.announce_rx.try_recv() { + seen.push(event.destination_hash); + let dest_hex = hex::encode(event.destination_hash); + let mut crypto_dirty = false; + tracing::info!( + dest = %dest_hex, + hops = event.hops, + "received announce" + ); + // LXMF app_data is the display name. + if let Some(ref data) = event.app_data + && let Ok(name) = std::str::from_utf8(data) + { + tracing::info!(dest = %dest_hex, name = %name, "announce display name"); + } + if let Some(ref data) = event.app_data + && let Some(pn) = lxmf_core::handlers::parse_pn_announce_data(data) + { + self.router + .set_stamp_cost(event.destination_hash, pn.stamp_cost); + tracing::debug!( + dest = %dest_hex, + stamp_cost = pn.stamp_cost, + "learned propagation-node stamp cost from announce" + ); + } + if let Some(pub_key) = event.public_key + && self.known_identities.get(&dest_hex) != Some(&pub_key) + { + self.known_identities.insert(dest_hex.clone(), pub_key); + crypto_dirty = true; + tracing::debug!(dest = %dest_hex, "learned identity key from announce"); + } + if let Some(ratchet_key) = event.ratchet { + self.received_ratchets + .insert(dest_hex.clone(), ReceivedRatchet::new(ratchet_key)); + crypto_dirty = true; + tracing::debug!(dest = %dest_hex, "learned ratchet from announce"); + } + if crypto_dirty { + self.save_crypto_state(); + } + } + seen + } + + fn drain_link_packets(&mut self) { + while let Ok((plaintext, link_id)) = self.link_packet_rx.try_recv() { + tracing::info!( + link_id = %hex::encode(link_id), + len = plaintext.len(), + "received decrypted packet via link" + ); + self.handle_link_delivered_data(&plaintext); + } + + while let Ok((data, link_id)) = self.resource_rx.try_recv() { + tracing::info!( + link_id = %hex::encode(link_id), + len = data.len(), + "resource transfer completed on link" + ); + self.handle_link_delivered_data(&data); + } + + while let Ok((data, link_id)) = self.prop_link_packet_rx.try_recv() { + tracing::info!( + link_id = %hex::encode(link_id), + len = data.len(), + "received propagation packet via link" + ); + self.handle_propagation_transfer_data(&data); + } + + while let Ok((data, link_id)) = self.prop_resource_rx.try_recv() { + tracing::info!( + link_id = %hex::encode(link_id), + len = data.len(), + "propagation resource transfer completed" + ); + self.handle_propagation_transfer_data(&data); + } + } + + fn handle_link_delivered_data(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // LxMessage::unpack expects [dest_hash][lxm_data]; prepend if the + // sender omitted it. + let unpack_data = if data.len() >= 16 && data[..16] == self.lxmf_dest_hash { + data.to_vec() + } else { + let mut full = self.lxmf_dest_hash.to_vec(); + full.extend_from_slice(data); + full + }; + + match LxMessage::unpack(&unpack_data) { + Ok(msg) => { + tracing::info!( + from = %hex::encode(msg.source_hash), + title = %msg.title, + len = msg.content.len(), + "inbound LXMF message via link" + ); + if self.should_reject_for_stamp(&msg) { + return; + } + self.handle_inbound_message(msg); + } + Err(e) => { + tracing::debug!("link data not an LXMF message: {e}"); + } + } + } + + fn handle_propagation_transfer_data(&mut self, data: &[u8]) { + let Some(ref pn) = self.propagation_node else { + tracing::debug!("received propagation data but node storage is disabled"); + return; + }; + + let (_remote_timebase, entries) = match LxMessage::unpack_propagation_wrapper(data) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!("failed to unpack propagation wrapper: {e}"); + return; + } + }; + + let min_cost = self + .config + .propagation_stamp_cost + .saturating_sub(lxmf_core::constants::PROPAGATION_COST_FLEX); + let mut accepted = 0usize; + let mut rejected = 0usize; + + if let Ok(mut node) = pn.lock() { + for entry in entries { + match lxmf_core::stamper::validate_pn_stamp(&entry, min_cost) { + Some((_transient_id, lxmf_data, stamp_value, _stamp_data)) => { + if node.accept_propagated_blob(&lxmf_data, stamp_value as u8) { + accepted += 1; + } + } + None => rejected += 1, + } + } + } + + tracing::info!(accepted, rejected, "processed inbound propagation transfer"); + } + + fn handle_propagation_downloaded_data(&mut self, data: &[u8]) { + if data.len() < 16 { + return; + } + + let unpack_data = if data[..16] == self.lxmf_dest_hash { + match self.decrypt_inbound(&data[16..]) { + Some(plaintext) => { + let mut full = self.lxmf_dest_hash.to_vec(); + full.extend_from_slice(&plaintext); + full + } + None => data.to_vec(), + } + } else { + data.to_vec() + }; + + match LxMessage::unpack(&unpack_data) { + Ok(mut msg) => { + msg.method = lxmf_core::constants::DeliveryMethod::Propagated; + tracing::info!( + from = %hex::encode(msg.source_hash), + title = %msg.title, + len = msg.content.len(), + "propagation: downloaded message" + ); + self.handle_inbound_message(msg); + } + Err(e) => { + tracing::warn!("failed to unpack downloaded propagation message: {e}"); + } + } + } + + fn handle_inbound_packet(&mut self, raw: &[u8]) { + let (header, rest) = match rns_wire::header::PacketHeader::unpack(raw) { + Ok(r) => r, + Err(e) => { + tracing::warn!("failed to parse inbound packet header: {e}"); + return; + } + }; + + let payload = &raw[rest..]; + if payload.is_empty() { + return; + } + + let plaintext = match self.decrypt_inbound(payload) { + Some(pt) => pt, + None => { + tracing::warn!("failed to decrypt inbound packet"); + return; + } + }; + + // Python strips the dest hash for opportunistic delivery; direct delivery + // keeps it. Re-prepend if missing so LxMessage::unpack always sees the + // [dest_hash][lxm_data] layout. + let unpack_data = if plaintext.len() >= 16 && plaintext[..16] == self.lxmf_dest_hash { + plaintext.clone() + } else { + let mut data = self.lxmf_dest_hash.to_vec(); + data.extend_from_slice(&plaintext); + data + }; + + match LxMessage::unpack(&unpack_data) { + Ok(msg) => { + tracing::info!( + from = %hex::encode(msg.source_hash), + title = %msg.title, + len = msg.content.len(), + "inbound LXMF message received" + ); + + // Reject on stamp failure BEFORE sending the delivery proof. + if self.should_reject_for_stamp(&msg) { + return; + } + + if let Some(proof_raw) = self.create_delivery_proof(raw) { + let trunc = + rns_wire::hash::truncated_packet_hash(raw, header.flags.header_type); + let _ = self.transport_tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(proof_raw), + destination_hash: trunc, + }, + )); + } + + self.handle_inbound_message(msg); + } + Err(e) => { + tracing::warn!("failed to unpack LXMF message: {e}"); + } + } + } + + /// Returns true if the message should be rejected. + fn should_reject_for_stamp(&self, msg: &LxMessage) -> bool { + if !self.config.enforce_stamps { + return false; + } + let required_cost = match self.config.stamp_cost { + Some(c) if c > 0 => c, + _ => return false, + }; + let stamp = match msg.stamp.as_deref() { + Some(s) => s, + None => { + tracing::warn!( + from = %hex::encode(msg.source_hash), + required_cost, + "inbound message rejected: no stamp (enforce_stamps=true)" + ); + return true; + } + }; + let message_id = match msg.message_id.or(msg.hash) { + Some(id) => id, + None => { + tracing::warn!( + from = %hex::encode(msg.source_hash), + "inbound message rejected: no message_id for stamp validation" + ); + return true; + } + }; + if !self.router.validate_stamp_with_tickets( + &message_id, + stamp, + required_cost, + &msg.source_hash, + ) { + tracing::warn!( + from = %hex::encode(msg.source_hash), + required_cost, + "inbound message rejected: stamp PoW invalid or below required cost" + ); + return true; + } + false + } + + /// Write a received LXMF message to disk and invoke `on_inbound`. + fn handle_inbound_message(&self, msg: LxMessage) { + // Also deposit into the propagation store (if enabled) so peers can + // download it via offer/get sync. + if let Some(ref pn) = self.propagation_node + && let Ok(mut node) = pn.lock() + && node.accept_message(&msg) + { + tracing::info!( + from = %hex::encode(msg.source_hash), + "propagation: message accepted into store" + ); + } + + let messages_dir = self.messages_dir.clone(); + std::fs::create_dir_all(&messages_dir).ok(); + + let msg_hash = msg + .hash + .map(hex::encode) + .unwrap_or_else(|| format!("{:.0}", now_f64())); + let msg_path = messages_dir.join(format!("{msg_hash}.lxm")); + + // Pack synchronously (CPU-bound, no IO) and offload the disk write + // to the blocking pool so a slow disk doesn't stall the lxmd runner + // task between inbound messages. + match msg.pack() { + Ok(packed) => { + let write_path = msg_path.clone(); + tokio::task::spawn_blocking(move || { + if let Err(e) = std::fs::write(&write_path, &packed) { + tracing::error!("failed to write message to {}: {e}", write_path.display()); + } else { + tracing::info!("message saved to {}", write_path.display()); + } + }); + } + Err(e) => { + tracing::error!("failed to pack message for storage: {e}"); + return; + } + } + + // Execute on_inbound command if configured + if let Some(ref cmd) = self.config.on_inbound_command + && let Err(e) = execute_on_inbound(cmd, &msg_path.to_string_lossy()) + { + tracing::error!("on_inbound command failed: {e}"); + } + + // Update known identity from sender + // (The source_hash to public_key mapping comes from announce processing, + // not directly from the message. Log for diagnostics.) + tracing::debug!( + from = %hex::encode(msg.source_hash), + "inbound message processed" + ); + } + + fn execute_encrypted_actions(&mut self, actions: Vec) { + for action in actions { + let (mut message, dest_hash, is_opportunistic) = match action { + OutboundAction::DeliverDirect { message, dest_hash } => (message, dest_hash, false), + OutboundAction::DeliverOpportunistic { message, dest_hash } => { + (message, dest_hash, true) + } + OutboundAction::DeliverPropagated { message, prop_hash } => { + let mut message = message; + let prop_hex = hex::encode(prop_hash); + if !self.known_identities.contains_key(&prop_hex) { + message.delivery_attempts += 1; + message.last_delivery_attempt = now_f64(); + let _ = self.transport_tx.try_send(TransportMessage::RequestPath { + destination_hash: prop_hash, + }); + tracing::warn!( + prop = %prop_hex, + attempts = message.delivery_attempts, + "propagation node identity unknown, requesting path before link delivery" + ); + self.router.send(message); + continue; + } + tracing::info!( + dest = %hex::encode(message.destination_hash), + prop = %hex::encode(prop_hash), + "routing message via propagation node" + ); + match self.pack_message_for_propagation(&mut message, prop_hash) { + Some(packed) => { + self.ensure_link_delivery(); + if let Some(ref mut ld) = self.link_delivery { + ld.start_packed_delivery(message, prop_hash, 1, packed, false); + } + } + None => { + tracing::warn!( + dest = %hex::encode(message.destination_hash), + "failed to prepare propagated LXMF message; re-queueing" + ); + self.router.send(message); + } + } + continue; + } + OutboundAction::Failed(_) | OutboundAction::Expired(_) => continue, + }; + + if message.stamp.is_none() + && let Some(cost) = self.router.get_stamp_cost(&message.destination_hash) + && cost > 0 + { + tracing::info!( + dest = %hex::encode(message.destination_hash), + cost = cost, + "generating stamp" + ); + message.stamp_cost = Some(cost); + message.get_stamp(); + } + + let dest_hex = hex::encode(dest_hash); + if !is_opportunistic { + if !self.known_identities.contains_key(&dest_hex) { + message.delivery_attempts += 1; + message.last_delivery_attempt = now_f64(); + let _ = self.transport_tx.try_send(TransportMessage::RequestPath { + destination_hash: dest_hash, + }); + tracing::warn!( + dest = %dest_hex, + attempts = message.delivery_attempts, + "destination key unknown, re-queuing direct link delivery" + ); + self.router.send(message); + continue; + } + + tracing::info!( + dest = %dest_hex, + "routing Direct LXMF message over link delivery" + ); + self.ensure_link_delivery(); + if let Some(ref mut ld) = self.link_delivery { + ld.start_delivery(message, dest_hash, 1); + } + continue; + } + + let msg_hash = message.hash; + let packed = match message.pack() { + Ok(p) => p, + Err(_) => continue, + }; + + // Opportunistic delivery strips the dest_hash prefix + // (Python LXMessage.py:629). + let encrypt_data = if is_opportunistic && packed.len() > 16 { + &packed[16..] + } else { + &packed + }; + + let payload = if let Some(ct) = self.encrypt_for_destination(&dest_hex, encrypt_data) { + tracing::info!( + dest = %dest_hex, + packed_len = packed.len(), + encrypted_len = ct.len(), + "outbound LXMF: encrypted" + ); + ct + } else { + // Destination key unknown; re-queue for later. + message.delivery_attempts += 1; + message.last_delivery_attempt = now_f64(); + tracing::warn!( + dest = %dest_hex, + attempts = message.delivery_attempts, + "destination key unknown, re-queuing" + ); + self.router.send(message); + continue; + }; + + let flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Data, + }; + let header = rns_wire::header::PacketHeader { + flags, + hops: 0, + transport_id: None, + destination_hash: dest_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut raw = header.pack(); + raw.extend_from_slice(&payload); + + // Escalate oversize packets to link delivery. + if raw.len() > rns_wire::constants::MTU { + tracing::info!( + dest = %dest_hex, + packet_len = raw.len(), + "packet exceeds MTU; routing to link delivery" + ); + self.ensure_link_delivery(); + if let Some(ref mut ld) = self.link_delivery { + ld.start_delivery(message, dest_hash, 1); + } + continue; + } + + match self.transport_tx.try_send(TransportMessage::Outbound( + rns_transport::messages::OutboundRequest { + raw: Bytes::from(raw.clone()), + destination_hash: dest_hash, + }, + )) { + Ok(()) => { + if let Some(hash) = msg_hash { + let (full, trunc) = rns_wire::hash::packet_hash_pair( + &raw, + rns_wire::flags::HeaderType::Header1, + ); + let _ = self + .transport_tx + .try_send(TransportMessage::RegisterReceipt { + truncated_hash: trunc, + full_hash: full, + msg_id: hex::encode(hash), + timeout: Some(Duration::from_secs(15)), + }); + tracing::info!(hash = %hex::encode(hash), "message sent"); + } + } + Err(e) => { + tracing::error!(dest = %dest_hex, error = %e, "failed to send; message dropped"); + } + } + } + } + + fn ensure_link_delivery(&mut self) { + if self.link_delivery.is_none() { + self.link_delivery = Some(lxmf_core::link_delivery::LinkDeliveryManager::new( + self.transport_tx.clone(), + Some(self.identity.get_public_key()), + self.identity.get_signing_key(), + )); + } + } + + fn encrypt_for_destination(&self, dest_hash_hex: &str, plaintext: &[u8]) -> Option> { + let pub_key = self.known_identities.get(dest_hash_hex)?; + let remote = Identity::from_public_key(pub_key).ok()?; + let ratchet_pub = self + .received_ratchets + .get(dest_hash_hex) + .filter(|rr| !rr.is_expired()) + .map(|rr| &rr.ratchet_pub); + remote.encrypt(plaintext, ratchet_pub).ok() + } + + fn pack_message_for_propagation( + &self, + message: &mut LxMessage, + prop_hash: [u8; 16], + ) -> Option> { + let dest_hex = hex::encode(message.destination_hash); + let target_cost = self.router.get_stamp_cost(&prop_hash).unwrap_or(0); + let (packed, _tid, stamp_value) = message + .pack_propagated_encrypted_with_stamp( + |plaintext| { + self.encrypt_for_destination(&dest_hex, plaintext) + .ok_or_else(|| { + lxmf_core::message::MessageError::PackFailed(format!( + "no identity key for destination {dest_hex}" + )) + }) + }, + target_cost, + ) + .ok()?; + tracing::debug!( + dest = %dest_hex, + prop = %hex::encode(prop_hash), + target_cost, + stamp_value, + packed_len = packed.len(), + "prepared propagation wrapper" + ); + Some(packed) + } + + fn decrypt_inbound(&self, ciphertext: &[u8]) -> Option> { + let prv_keys = self.ratchet_ring.private_keys(); + let refs: Vec<&[u8; 32]> = prv_keys.iter().collect(); + let ratchets = if refs.is_empty() { + None + } else { + Some(refs.as_slice()) + }; + self.identity.decrypt(ciphertext, ratchets, false).ok() + } + + fn create_delivery_proof(&self, raw_packet: &[u8]) -> Option> { + let (header, _) = rns_wire::header::PacketHeader::unpack(raw_packet).ok()?; + let full_hash = rns_wire::hash::packet_hash(raw_packet, header.flags.header_type); + let trunc_hash = + rns_wire::hash::truncated_packet_hash(raw_packet, header.flags.header_type); + + let signature = self.identity.sign(&full_hash)?; + + let proof_flags = rns_wire::flags::PacketFlags { + header_type: rns_wire::flags::HeaderType::Header1, + context_flag: false, + transport_type: rns_wire::flags::TransportType::Broadcast, + destination_type: rns_wire::flags::DestinationType::Single, + packet_type: rns_wire::flags::PacketType::Proof, + }; + let proof_header = rns_wire::header::PacketHeader { + flags: proof_flags, + hops: 0, + transport_id: None, + destination_hash: trunc_hash, + context: rns_wire::context::PacketContext::None, + }; + let mut proof_raw = proof_header.pack(); + proof_raw.extend_from_slice(&signature); + Some(proof_raw) + } + + fn save_crypto_state(&self) { + let ratchet_dir = self.ratchets_dir.clone(); + std::fs::create_dir_all(&ratchet_dir).ok(); + + let ring_path = ratchet_dir.join("ring"); + let sig = self + .identity + .sign( + self.ratchet_ring + .current_public_key() + .unwrap_or([0u8; 32]) + .as_ref(), + ) + .unwrap_or([0u8; 64]); + if let Err(e) = self.ratchet_ring.save(&ring_path, &sig) { + tracing::warn!("Failed to save ratchet ring: {e}"); + } + + let received_dir = ratchet_dir.join("received"); + std::fs::create_dir_all(&received_dir).ok(); + for (hash_hex, rr) in &self.received_ratchets { + let path = received_dir.join(format!("{hash_hex}.ratchet")); + if let Err(e) = rr.save(&path) { + tracing::warn!("Failed to save received ratchet {hash_hex}: {e}"); + } + } + + // Flat binary: [dest_hash:16][pub:64] per entry. + let ki_path = ratchet_dir.join("known_identities"); + let mut data = Vec::with_capacity(self.known_identities.len() * 80); + for (hash_hex, pk) in &self.known_identities { + if let Ok(hash_bytes) = hex::decode(hash_hex) + && hash_bytes.len() == 16 + { + data.extend_from_slice(&hash_bytes); + data.extend_from_slice(pk); + } + } + if let Err(e) = rns_identity::persistence::atomic_write(&ki_path, &data) { + tracing::warn!("Failed to save known identities: {e}"); + } + } +} + +#[tokio::main] +pub(crate) async fn main() { + let args = Args::parse(); + + if args.exampleconfig { + print!("{}", example_config()); + return; + } + + setup_logging(args.verbose, args.quiet, args.service); + + let (config_dir, rns_config_dir) = + resolve_config_dirs(args.config.as_deref(), args.rnsconfig.as_deref()); + + let is_control_command = + args.status || args.peers || args.sync.is_some() || args.unpeer.is_some(); + let control_preflight = if is_control_command { + let peer_hash = if args.status || args.peers { + None + } else { + args.sync.as_deref().or(args.unpeer.as_deref()) + }; + match preflight_control_command( + &config_dir, + args.identity.as_deref(), + peer_hash, + args.remote.as_deref(), + ) { + Ok(preflight) => Some(preflight), + Err(e) => { + println!("{}", e.message); + std::process::exit(e.exit_code); + } + } + } else { + None + }; + + let config_path = config_dir.join("config"); + let config = match rns_runtime::config::Config::from_file(&config_path) { + Ok(c) => c, + Err(e) => { + tracing::warn!( + "Could not load config from {}: {}", + config_path.display(), + e + ); + tracing::info!("Using default configuration"); + rns_runtime::config::Config::parse(rns_runtime::config::Config::default_config()) + .expect("default config must parse") + } + }; + + let mut daemon_config = DaemonConfig::from_config(&config); + if args.propagation_node { + daemon_config.propagation_enabled = true; + } + if let Some(ref on_inbound) = args.on_inbound { + daemon_config.on_inbound_command = Some(on_inbound.clone()); + } + + tracing::info!("LXMF Daemon starting"); + if let Some(ref name) = daemon_config.display_name { + tracing::info!("Display name: {}", name); + } + + if daemon_config.propagation_enabled { + tracing::info!( + "Propagation node enabled (stamp_cost={}, max_peers={}, autopeer={})", + daemon_config.propagation_stamp_cost, + daemon_config.max_peers, + daemon_config.autopeer, + ); + } + + let shutdown = rns_runtime::lifecycle::ShutdownSignal::new(); + let shutdown_clone = shutdown.clone(); + + tokio::spawn(async move { + if let Ok(()) = tokio::signal::ctrl_c().await { + tracing::info!("Received shutdown signal"); + shutdown_clone.trigger(); + } + }); + + let rns_config_dir_str = rns_config_dir.to_string_lossy().to_string(); + let is_foreground = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let rns_handle = match rns_runtime::reticulum::init( + Some(&rns_config_dir_str), + None, + shutdown.clone(), + is_foreground, + ) + .await + { + Ok(h) => { + tracing::info!( + "RNS initialized: mode={:?}, interfaces={}", + h.instance_mode, + h.interface_configs.len(), + ); + h + } + Err(e) => { + tracing::error!("Failed to initialize RNS: {e:?}"); + return; + } + }; + rns_handle + .enable_on_network_discovery(Arc::new( + lxmf_core::discovery_stamper::LxmfDiscoveryStamper::default(), + )) + .await; + + let transport_tx = rns_handle.transport_tx.clone(); + + if let Some(preflight) = control_preflight { + let identity = match Identity::from_file(&preflight.identity_path) { + Ok(identity) => identity, + Err(_) => { + println!( + "Could not load the Primary Identity from {}", + preflight.identity_path.display() + ); + std::process::exit(4); + } + }; + let target_identity_hash = match preflight.remote_hash { + Some(remote_hash) => { + match resolve_remote_identity_hash(transport_tx.clone(), remote_hash, 5.0).await { + Ok(identity_hash) => identity_hash, + Err(_) => { + println!("Resolving remote identity timed out, exiting now"); + std::process::exit(200); + } + } + } + None => identity.hash, + }; + let timeout = args + .timeout + .unwrap_or(if args.status || args.peers { 5.0 } else { 10.0 }) + .max(0.0); + + if args.status || args.peers { + let response_bytes = match query_control( + transport_tx.clone(), + identity, + target_identity_hash, + lxmf_core::constants::STATS_GET_PATH, + Vec::new(), + timeout, + ) + .await + { + Ok(response) => response, + Err(error) => print_control_link_error(ControlCommandKind::Status, &error), + }; + let response = decode_control_response(&response_bytes); + exit_for_control_response(ControlCommandKind::Status, &response); + match response { + ControlResponse::Stats(stats) => { + print!( + "{}", + format_remote_status(&stats, args.status, args.peers, now_f64()) + ); + } + _ => { + println!("Empty response received"); + std::process::exit(207); + } + } + return; + } + + if args.sync.is_some() { + let peer_hash = preflight + .peer_hash + .expect("sync preflight should include peer hash"); + let response_bytes = match query_control( + transport_tx.clone(), + identity, + target_identity_hash, + lxmf_core::constants::SYNC_REQUEST_PATH, + peer_hash.to_vec(), + timeout, + ) + .await + { + Ok(response) => response, + Err(error) => print_control_link_error(ControlCommandKind::Sync, &error), + }; + let response = decode_control_response(&response_bytes); + exit_for_control_response(ControlCommandKind::Sync, &response); + println!("Sync requested for peer <{}>", hex::encode(peer_hash)); + return; + } + + if args.unpeer.is_some() { + let peer_hash = preflight + .peer_hash + .expect("unpeer preflight should include peer hash"); + let response_bytes = match query_control( + transport_tx.clone(), + identity, + target_identity_hash, + lxmf_core::constants::UNPEER_REQUEST_PATH, + peer_hash.to_vec(), + timeout, + ) + .await + { + Ok(response) => response, + Err(error) => print_control_link_error(ControlCommandKind::Unpeer, &error), + }; + let response = decode_control_response(&response_bytes); + exit_for_control_response(ControlCommandKind::Unpeer, &response); + println!("Broke peering with <{}>", hex::encode(peer_hash)); + return; + } + } + + let mut runner = match LxmdRunner::new(daemon_config.clone(), &config_dir, transport_tx) { + Ok(r) => r, + Err(e) => { + tracing::error!("Failed to initialize LXMF daemon: {e}"); + return; + } + }; + + runner.apply_config(); + + if let Err(e) = runner.router.load_state(&runner.data_dir) { + tracing::warn!("Failed to load persisted router state: {e}"); + } else { + tracing::info!( + "Loaded persisted router state from {}", + runner.data_dir.display() + ); + } + + let ignored = load_hash_list(&config_dir.join("ignored")); + if !ignored.is_empty() { + tracing::info!( + "Loaded {} ignored destination(s) from ignored", + ignored.len() + ); + runner.router.ignored.extend(ignored); + } + let allowed = load_hash_list(&config_dir.join("allowed")); + if !allowed.is_empty() { + tracing::info!( + "Loaded {} allowed destination(s) from allowed", + allowed.len() + ); + runner.router.allowed.extend(allowed); + } + + runner.refresh_control_state(); + + tracing::info!("LXMF router initialized"); + + // Startup announce: wait until at least one interface is online, mirroring + // Python's deferred_start_jobs() pattern. + if daemon_config.announce_at_start { + tracing::info!("Waiting for interfaces to come online before announcing..."); + let mut announced = false; + for _ in 0..30 { + let (otx, orx) = tokio::sync::oneshot::channel(); + let _ = runner + .transport_tx + .send(TransportMessage::Rpc { + query: rns_transport::messages::TransportQuery::GetInterfaceStats, + response_tx: otx, + }) + .await; + if let Ok(rns_transport::messages::TransportQueryResponse::InterfaceStats(stats)) = + orx.await + { + let any_online = stats + .iter() + .any(|s| s.online && (s.rx_bytes > 0 || s.tx_bytes > 0)); + if any_online { + match runner.send_announce().await { + Ok(()) => { + tracing::info!("Startup announce sent (interface online)"); + runner.last_peer_announce = now_f64(); + announced = true; + } + Err(e) => tracing::warn!("Failed to send startup announce: {e}"), + } + break; + } + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + if !announced { + tracing::warn!("No online interface detected after 30s, announcing anyway"); + let _ = runner.send_announce().await; + runner.last_peer_announce = now_f64(); + } + } + + if daemon_config.node_announce_at_start && daemon_config.propagation_enabled { + match runner.send_propagation_announce().await { + Ok(()) => { + tracing::info!("Startup propagation announce sent"); + if runner.should_announce_control() { + match runner.send_control_announce().await { + Ok(()) => tracing::info!("Startup control announce sent"), + Err(e) => tracing::warn!("Failed to send startup control announce: {e}"), + } + } + runner.last_node_announce = now_f64(); + } + Err(e) => tracing::warn!("Failed to send startup propagation announce: {e}"), + } + } + + if let Some(ref cmd) = daemon_config.on_inbound_command { + tracing::info!("On-inbound command: {}", cmd); + } + + if let Some(ref send_args) = args.send { + let dest_hex = normalize_hash_hex(&send_args[0]); + let content = match args.send_file.as_ref() { + Some(path) => match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + tracing::error!(path = %path.display(), error = %e, "failed to read --send-file"); + std::process::exit(1); + } + }, + None => match send_args.get(1) { + Some(content) => content.clone(), + None => { + tracing::error!("--send requires CONTENT unless --send-file is provided"); + std::process::exit(1); + } + }, + }; + + let dest_hash = match parse_destination_hash(&dest_hex) { + Ok(hash) => hash, + Err(e) => { + tracing::error!("{e}"); + std::process::exit(1); + } + }; + + tracing::info!(dest = %dest_hex, "sending message..."); + runner.link_delivery_failures.clear(); + + // Wait up to 15s for a fresh announce so we learn the destination's key and + // install a current path before queueing. A persisted key alone is not enough + // behind transport hubs: link delivery can start before the path exists. + let mut have_key = runner.known_identities.contains_key(&dest_hex); + let mut saw_dest_announce = false; + for _ in 0..30 { + for announced in runner.drain_announce_events() { + if announced == dest_hash { + saw_dest_announce = true; + } + } + runner.drain_link_packets(); + have_key = runner.known_identities.contains_key(&dest_hex); + if have_key && saw_dest_announce { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + if !have_key { + tracing::warn!( + dest = %dest_hex, + "no announce received for destination in 15s; sending anyway" + ); + } else if !saw_dest_announce { + tracing::warn!( + dest = %dest_hex, + "no fresh path announce received for destination in 15s; sending anyway" + ); + } + + let mut msg = LxMessage::new( + dest_hash, + runner.lxmf_dest_hash, + "", + &content, + args.send_method.delivery_method(), + ); + if let Some(raw) = args.send_fields_json.as_deref() { + match parse_send_fields_json(raw) { + Ok(fields) => { + tracing::info!(count = fields.len(), "attaching custom fields to --send"); + msg.fields = fields; + } + Err(e) => { + tracing::error!("--send-fields-json: {e}"); + std::process::exit(1); + } + } + } + let Some(signing_key) = runner.identity.get_signing_key() else { + tracing::error!("identity has no signing key"); + std::process::exit(1); + }; + if let Err(e) = msg.sign(&signing_key) { + tracing::error!(error = ?e, "failed to sign message"); + std::process::exit(1); + } + runner.router.send(msg); + + // Drain phase: tick until the message leaves the router queue. + // 30 iterations absorbs one full DELIVERY_RETRY_WAIT (10s) backoff. + let mut drained = false; + for _ in 0..30 { + runner.drain_announce_events(); + runner.drain_link_packets(); + runner.tick(); + tokio::time::sleep(Duration::from_secs(1)).await; + + let stats = runner.router.stats(); + if stats.pending_outbound == 0 && stats.pending_deferred_stamps == 0 { + drained = true; + break; + } + } + + if !drained { + tracing::warn!("message send timed out (router queue never drained)"); + eprintln!("Error: send timed out (destination may be unreachable)"); + std::process::exit(1); + } + + // Link-delivery completion phase: when escalated to link delivery + // (Opportunistic>MTU auto-downgrade, Direct, or Propagated), the + // router queue empties immediately but the transfer continues on the + // link. Wait up to 90s so the proof can come back. + if runner + .link_delivery + .as_ref() + .is_some_and(|ld| ld.pending_count() > 0) + { + tracing::info!("waiting for link delivery to complete..."); + let mut link_done = false; + for _ in 0..args.send_timeout_secs { + runner.drain_announce_events(); + runner.drain_link_packets(); + runner.tick(); + tokio::time::sleep(Duration::from_secs(1)).await; + + if runner + .link_delivery + .as_ref() + .is_none_or(|ld| ld.pending_count() == 0) + { + link_done = true; + break; + } + } + if !link_done { + tracing::warn!( + timeout_secs = args.send_timeout_secs, + "link delivery did not complete before timeout" + ); + eprintln!("Error: link delivery did not complete in time"); + std::process::exit(1); + } + } + if let Some(reason) = runner.link_delivery_failures.last() { + tracing::warn!(reason = %reason, "message send failed during link delivery"); + eprintln!("Error: link delivery failed: {reason}"); + std::process::exit(1); + } + + tracing::info!("message sent successfully"); + println!("Message sent to {}", dest_hex); + std::process::exit(0); + } + + tracing::info!("LXMF Daemon running. Press Ctrl+C to stop."); + + // Event-driven for inbound, periodic for outbound and maintenance. + let mut tick_timer = tokio::time::interval(Duration::from_secs(4)); + tick_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = shutdown.wait() => break, + _ = tick_timer.tick() => { + runner.drain_announce_events(); + runner.drain_link_packets(); + runner.tick(); + } + Some(raw) = runner.inbound_raw_rx.recv() => { + runner.handle_inbound_packet(&raw); + } + Some((plaintext, _link_id)) = runner.link_packet_rx.recv() => { + runner.handle_link_delivered_data(&plaintext); + runner.drain_link_packets(); + } + Some((data, _link_id)) = runner.prop_link_packet_rx.recv() => { + runner.handle_propagation_transfer_data(&data); + runner.drain_link_packets(); + } + Some((data, _link_id)) = runner.prop_resource_rx.recv() => { + runner.handle_propagation_transfer_data(&data); + runner.drain_link_packets(); + } + } + } + + tracing::info!("LXMF Daemon shutting down"); + runner.save_crypto_state(); + if let Err(e) = runner.router.save_state(&runner.data_dir) { + tracing::warn!("Failed to save router state on shutdown: {e}"); + } + tracing::info!("Crypto state saved"); + tracing::info!("LXMF Daemon stopped"); +} diff --git a/crates/lxmf-tools/src/daemon.rs b/crates/lxmf-tools/src/daemon.rs new file mode 100644 index 0000000..c54e11a --- /dev/null +++ b/crates/lxmf-tools/src/daemon.rs @@ -0,0 +1,657 @@ +//! LXMF daemon configuration and runner. +//! +//! Python reference: LXMF/Utilities/lxmd.py. + +use lxmf_core::constants::*; +use lxmf_core::router::{LxmRouter, RouterConfig, RouterConfigExt}; +use rns_runtime::config::{Config, ConfigSection}; + +/// Normalized view of Python `lxmd.apply_config()` behavior. +/// +/// This intentionally mirrors Python's active_configuration keys and units. +/// It is kept separate from [`DaemonConfig`] while the daemon still has legacy +/// Rust fields and storage layout. +#[derive(Debug, Clone, PartialEq)] +pub struct PythonLxmdConfig { + pub display_name: String, + pub peer_announce_at_start: bool, + pub peer_announce_interval: Option, + pub delivery_transfer_max_accepted_size: f64, + pub on_inbound: Option, + pub enable_propagation_node: bool, + pub node_name: Option, + pub auth_required: bool, + pub node_announce_at_start: bool, + pub autopeer: bool, + pub autopeer_maxdepth: Option, + pub node_announce_interval: Option, + pub message_storage_limit: f64, + pub propagation_transfer_max_accepted_size: f64, + pub propagation_sync_max_accepted_size: f64, + pub propagation_stamp_cost_target: i64, + pub propagation_stamp_cost_flexibility: i64, + pub peering_cost: i64, + pub remote_peering_cost_max: i64, + pub prioritised_lxmf_destinations: Vec, + pub control_allowed_identities: Vec, + pub static_peers: Vec, + pub max_peers: Option, + pub from_static_only: bool, + pub target_loglevel: Option, +} + +impl PythonLxmdConfig { + pub fn from_config(config: &Config) -> Self { + let lxmf = config.section("lxmf"); + let propagation = config.section("propagation"); + let logging = config.section("logging"); + + let propagation_transfer_max_accepted_size = propagation + .and_then(|sec| sec.get_float("propagation_message_max_accepted_size")) + .map(|v| v.max(0.38)) + .unwrap_or(256.0); + + Self { + display_name: lxmf + .and_then(|sec| sec.get("display_name")) + .unwrap_or("Anonymous Peer") + .to_string(), + peer_announce_at_start: get_bool_or(lxmf, "announce_at_start", false), + peer_announce_interval: get_int(lxmf, "announce_interval").map(|v| v * 60), + delivery_transfer_max_accepted_size: get_float_or_floor( + lxmf, + "delivery_transfer_max_accepted_size", + 1000.0, + 0.38, + ), + on_inbound: lxmf + .and_then(|sec| sec.get("on_inbound")) + .map(ToString::to_string), + enable_propagation_node: get_bool_or(propagation, "enable_node", false), + node_name: propagation + .and_then(|sec| sec.get("node_name")) + .map(ToString::to_string), + auth_required: get_bool_or(propagation, "auth_required", false), + node_announce_at_start: get_bool_or(propagation, "announce_at_start", false), + autopeer: get_bool_or(propagation, "autopeer", true), + autopeer_maxdepth: get_int(propagation, "autopeer_maxdepth"), + node_announce_interval: get_int(propagation, "announce_interval").map(|v| v * 60), + message_storage_limit: get_float_or_floor( + propagation, + "message_storage_limit", + 500.0, + 0.005, + ), + propagation_transfer_max_accepted_size, + propagation_sync_max_accepted_size: get_float_or_floor( + propagation, + "propagation_sync_max_accepted_size", + 256.0 * 40.0, + 0.38, + ), + propagation_stamp_cost_target: get_int(propagation, "propagation_stamp_cost_target") + .map(|v| v.max(PROPAGATION_COST_MIN as i64)) + .unwrap_or(PROPAGATION_COST as i64), + propagation_stamp_cost_flexibility: get_int( + propagation, + "propagation_stamp_cost_flexibility", + ) + .map(|v| v.max(0)) + .unwrap_or(PROPAGATION_COST_FLEX as i64), + peering_cost: get_int(propagation, "peering_cost") + .map(|v| v.max(0)) + .unwrap_or(PEERING_COST as i64), + remote_peering_cost_max: get_int(propagation, "remote_peering_cost_max") + .map(|v| v.max(0)) + .unwrap_or(MAX_PEERING_COST as i64), + prioritised_lxmf_destinations: get_list(propagation, "prioritise_destinations"), + control_allowed_identities: get_list(propagation, "control_allowed"), + static_peers: get_list(propagation, "static_peers"), + max_peers: get_int(propagation, "max_peers"), + from_static_only: get_bool_or(propagation, "from_static_only", false), + target_loglevel: get_int(logging, "loglevel"), + } + } +} + +fn get_bool_or(section: Option<&ConfigSection>, key: &str, default: bool) -> bool { + section.and_then(|sec| sec.get_bool(key)).unwrap_or(default) +} + +fn get_int(section: Option<&ConfigSection>, key: &str) -> Option { + section.and_then(|sec| sec.get_int(key)) +} + +fn get_float_or_floor(section: Option<&ConfigSection>, key: &str, default: f64, floor: f64) -> f64 { + section + .and_then(|sec| sec.get_float(key)) + .map(|value| value.max(floor)) + .unwrap_or(default) +} + +fn get_list(section: Option<&ConfigSection>, key: &str) -> Vec { + section + .and_then(|sec| sec.get_list(key)) + .unwrap_or_default() +} + +/// Daemon configuration parsed from an INI config file. +#[derive(Debug, Clone)] +pub struct DaemonConfig { + pub display_name: Option, + pub node_name: Option, + pub announce_at_start: bool, + pub announce_interval: Option, + pub stamp_cost: Option, + pub propagation_enabled: bool, + pub outbound_propagation_node: Option, + pub propagation_stamp_cost: u8, + pub propagation_stamp_flex: u8, + pub peering_cost: u8, + pub max_peering_cost: u8, + pub max_peers: usize, + pub autopeer: bool, + pub autopeer_maxdepth: usize, + pub propagation_limit_kb: usize, + pub sync_limit_kb: usize, + pub on_inbound_command: Option, + pub node_announce_at_start: bool, + pub node_announce_interval: Option, + pub auth_required: bool, + pub control_allowed: Vec, + pub static_peers: Vec, + pub prioritise_destinations: Vec, + pub enforce_ratchets: bool, + pub enforce_stamps: bool, + pub message_storage_limit: Option, + pub from_static_only: bool, + /// Max accepted inbound delivery transfer size in KB. Python reference: + /// `delivery_transfer_max_accepted_size` in `lxmd.py`. + pub delivery_transfer_max_accepted_size: usize, +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + display_name: Some("Anonymous Peer".to_string()), + node_name: None, + announce_at_start: false, + announce_interval: None, + stamp_cost: None, + propagation_enabled: false, + outbound_propagation_node: None, + propagation_stamp_cost: PROPAGATION_COST, + propagation_stamp_flex: PROPAGATION_COST_FLEX, + peering_cost: PEERING_COST, + max_peering_cost: MAX_PEERING_COST, + max_peers: MAX_PEERS, + autopeer: true, + autopeer_maxdepth: AUTOPEER_MAXDEPTH, + propagation_limit_kb: PROPAGATION_LIMIT, + sync_limit_kb: SYNC_LIMIT, + on_inbound_command: None, + node_announce_at_start: false, + node_announce_interval: None, + auth_required: false, + control_allowed: Vec::new(), + static_peers: Vec::new(), + prioritise_destinations: Vec::new(), + enforce_ratchets: false, + enforce_stamps: false, + message_storage_limit: Some(500_000_000), + from_static_only: false, + delivery_transfer_max_accepted_size: DELIVERY_LIMIT, + } + } +} + +impl DaemonConfig { + pub fn to_router_config(&self) -> RouterConfig { + RouterConfig { + propagation_enabled: self.propagation_enabled, + autopeer: self.autopeer, + max_peers: self.max_peers, + propagation_limit_kb: self.propagation_limit_kb, + delivery_limit_kb: self.delivery_transfer_max_accepted_size, + sync_limit_kb: self.sync_limit_kb, + propagation_stamp_cost: self.propagation_stamp_cost, + propagation_stamp_flex: self.propagation_stamp_flex, + stamp_cost: self.stamp_cost, + ext: RouterConfigExt { + autopeer_maxdepth: self.autopeer_maxdepth, + peering_cost: self.peering_cost, + max_peering_cost: self.max_peering_cost, + enforce_ratchets: self.enforce_ratchets, + enforce_stamps: self.enforce_stamps, + auth_required: self.auth_required, + message_storage_limit: self.message_storage_limit, + name: self.node_name.clone(), + from_static_only: self.from_static_only, + ..Default::default() + }, + } + } + + /// Parse from `[lxmf]`, `[propagation]`, and `[control]` sections. + pub fn from_config(config: &Config) -> Self { + let py = PythonLxmdConfig::from_config(config); + let mut dc = DaemonConfig { + display_name: Some(py.display_name), + node_name: py.node_name, + announce_at_start: py.peer_announce_at_start, + announce_interval: seconds_to_u64(py.peer_announce_interval), + propagation_enabled: py.enable_propagation_node, + propagation_stamp_cost: clamp_python_cost_to_u8( + py.propagation_stamp_cost_target, + PROPAGATION_COST_MIN as i64, + ), + propagation_stamp_flex: clamp_python_cost_to_u8( + py.propagation_stamp_cost_flexibility, + 0, + ), + peering_cost: clamp_python_cost_to_u8(py.peering_cost, 0), + max_peering_cost: clamp_python_cost_to_u8(py.remote_peering_cost_max, 0), + max_peers: py + .max_peers + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(MAX_PEERS), + autopeer: py.autopeer, + autopeer_maxdepth: py + .autopeer_maxdepth + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(AUTOPEER_MAXDEPTH), + propagation_limit_kb: kb_to_usize_ceil(py.propagation_transfer_max_accepted_size), + sync_limit_kb: kb_to_usize_ceil(py.propagation_sync_max_accepted_size), + on_inbound_command: py.on_inbound, + node_announce_at_start: py.node_announce_at_start, + node_announce_interval: seconds_to_u64(py.node_announce_interval), + auth_required: py.auth_required, + control_allowed: py.control_allowed_identities, + static_peers: py.static_peers, + prioritise_destinations: py.prioritised_lxmf_destinations, + message_storage_limit: megabytes_to_bytes(py.message_storage_limit), + from_static_only: py.from_static_only, + delivery_transfer_max_accepted_size: kb_to_usize_ceil( + py.delivery_transfer_max_accepted_size, + ), + ..DaemonConfig::default() + }; + + if let Some(sec) = config.section("lxmf") + && let Some(cost) = sec.get_uint("stamp_cost") + { + dc.stamp_cost = Some(cost as u8); + } + + if let Some(sec) = config.section("propagation") { + if let Some(node) = sec.get("outbound_node") { + let trimmed = node.trim(); + if !trimmed.is_empty() { + dc.outbound_propagation_node = Some(trimmed.to_string()); + } + } + if get_int(Some(sec), "propagation_stamp_cost_target").is_none() + && let Some(cost) = sec.get_uint("propagation_stamp_cost") + { + dc.propagation_stamp_cost = cost as u8; + } + if get_float(Some(sec), "propagation_message_max_accepted_size").is_none() + && get_float(Some(sec), "propagation_transfer_max_accepted_size").is_none() + && let Some(limit) = sec.get_uint("propagation_limit") + { + dc.propagation_limit_kb = limit as usize; + } + dc.enforce_ratchets = sec.get_bool_or("enforce_ratchets", false); + dc.enforce_stamps = sec.get_bool_or("enforce_stamps", false); + } + + if let Some(sec) = config.section("control") { + if !dc.auth_required { + dc.auth_required = sec.get_bool_or("auth_required", false); + } + if dc.control_allowed.is_empty() + && let Some(allowed) = sec.get("allowed") + { + dc.control_allowed = allowed + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + + dc + } +} + +fn clamp_python_cost_to_u8(value: i64, floor: i64) -> u8 { + value.max(floor).min(u8::MAX as i64) as u8 +} + +fn get_float(section: Option<&ConfigSection>, key: &str) -> Option { + section.and_then(|sec| sec.get_float(key)) +} + +fn seconds_to_u64(value: Option) -> Option { + value.map(|seconds| seconds.max(0) as u64) +} + +fn kb_to_usize_ceil(value: f64) -> usize { + value.max(0.0).ceil().max(1.0) as usize +} + +fn megabytes_to_bytes(value: f64) -> Option { + let bytes = (value.max(0.0) * 1_000_000.0) as usize; + (bytes > 0).then_some(bytes) +} + +pub fn create_router(config: &DaemonConfig) -> LxmRouter { + LxmRouter::new(config.to_router_config()) +} + +pub fn create_router_with_transport( + config: &DaemonConfig, + transport_tx: tokio::sync::mpsc::Sender, +) -> LxmRouter { + let mut router = LxmRouter::new(config.to_router_config()); + router.set_transport(transport_tx); + router +} + +/// Execute an on_inbound hook. +/// +/// Runs `Command::new(prog).arg(...)` with `message_path` as a separate +/// argument rather than interpolating into a shell string, so untrusted path +/// contents cannot inject shell metacharacters. +pub fn execute_on_inbound(command: &str, message_path: &str) -> std::io::Result<()> { + use std::process::Command; + + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Ok(()); + } + + let mut cmd = Command::new(parts[0]); + for arg in &parts[1..] { + cmd.arg(arg); + } + cmd.arg(message_path); + + let status = cmd.status()?; + if !status.success() { + tracing::warn!("on_inbound command exited with status: {}", status); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let dc = DaemonConfig::default(); + assert_eq!(dc.display_name.as_deref(), Some("Anonymous Peer")); + assert!(!dc.announce_at_start); + assert_eq!(dc.announce_interval, None); + assert!(!dc.propagation_enabled); + assert_eq!(dc.propagation_stamp_cost, 16); + assert_eq!(dc.propagation_stamp_flex, 3); + assert_eq!(dc.peering_cost, 18); + assert_eq!(dc.max_peering_cost, 26); + assert_eq!(dc.max_peers, 20); + assert!(dc.autopeer); + assert_eq!(dc.autopeer_maxdepth, AUTOPEER_MAXDEPTH); + assert_eq!(dc.propagation_limit_kb, 256); + assert_eq!(dc.sync_limit_kb, 10_240); + assert!(!dc.node_announce_at_start); + assert_eq!(dc.node_announce_interval, None); + assert_eq!(dc.message_storage_limit, Some(500_000_000)); + assert_eq!(dc.delivery_transfer_max_accepted_size, 1000); + assert!(!dc.from_static_only); + } + + #[test] + fn python_normalized_config_matches_omitted_defaults() { + let config = rns_runtime::config::Config::parse("").unwrap(); + let py = PythonLxmdConfig::from_config(&config); + + assert_eq!(py.display_name, "Anonymous Peer"); + assert!(!py.peer_announce_at_start); + assert_eq!(py.peer_announce_interval, None); + assert_eq!(py.delivery_transfer_max_accepted_size, 1000.0); + assert_eq!(py.on_inbound, None); + assert!(!py.enable_propagation_node); + assert_eq!(py.node_name, None); + assert!(!py.auth_required); + assert!(!py.node_announce_at_start); + assert!(py.autopeer); + assert_eq!(py.autopeer_maxdepth, None); + assert_eq!(py.node_announce_interval, None); + assert_eq!(py.message_storage_limit, 500.0); + assert_eq!(py.propagation_transfer_max_accepted_size, 256.0); + assert_eq!(py.propagation_sync_max_accepted_size, 10240.0); + assert_eq!(py.propagation_stamp_cost_target, 16); + assert_eq!(py.propagation_stamp_cost_flexibility, 3); + assert_eq!(py.peering_cost, 18); + assert_eq!(py.remote_peering_cost_max, 26); + assert!(py.prioritised_lxmf_destinations.is_empty()); + assert!(py.control_allowed_identities.is_empty()); + assert!(py.static_peers.is_empty()); + assert_eq!(py.max_peers, None); + assert!(!py.from_static_only); + assert_eq!(py.target_loglevel, None); + } + + #[test] + fn python_normalized_config_matches_units_floors_and_lists() { + let input = r#" +[propagation] +announce_interval = 2 +message_storage_limit = 0.001 +propagation_message_max_accepted_size = 0.1 +propagation_sync_max_accepted_size = 0.1 +propagation_stamp_cost_target = 1 +propagation_stamp_cost_flexibility = -9 +peering_cost = -1 +remote_peering_cost_max = -2 +static_peers = 00112233445566778899aabbccddeeff +prioritise_destinations = 0102030405060708090a0b0c0d0e0f10 +control_allowed = 11111111111111111111111111111111 +from_static_only = yes +max_peers = 7 + +[lxmf] +announce_interval = 3 +delivery_transfer_max_accepted_size = 0.1 + +[logging] +loglevel = 6 +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let py = PythonLxmdConfig::from_config(&config); + + assert_eq!(py.peer_announce_interval, Some(180)); + assert_eq!(py.node_announce_interval, Some(120)); + assert_eq!(py.delivery_transfer_max_accepted_size, 0.38); + assert_eq!(py.message_storage_limit, 0.005); + assert_eq!(py.propagation_transfer_max_accepted_size, 0.38); + assert_eq!(py.propagation_sync_max_accepted_size, 0.38); + assert_eq!(py.propagation_stamp_cost_target, 13); + assert_eq!(py.propagation_stamp_cost_flexibility, 0); + assert_eq!(py.peering_cost, 0); + assert_eq!(py.remote_peering_cost_max, 0); + assert_eq!(py.static_peers, ["00112233445566778899aabbccddeeff"]); + assert_eq!( + py.prioritised_lxmf_destinations, + ["0102030405060708090a0b0c0d0e0f10"] + ); + assert_eq!( + py.control_allowed_identities, + ["11111111111111111111111111111111"] + ); + assert_eq!(py.max_peers, Some(7)); + assert!(py.from_static_only); + assert_eq!(py.target_loglevel, Some(6)); + } + + #[test] + fn python_normalized_config_keeps_legacy_transfer_overwrite() { + let input = r#" +[propagation] +propagation_transfer_max_accepted_size = 12 +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let py = PythonLxmdConfig::from_config(&config); + + assert_eq!( + py.propagation_transfer_max_accepted_size, 256.0, + "Python 0.9.6 ignores legacy propagation_transfer_max_accepted_size unless the newer key is set" + ); + } + + #[test] + fn daemon_config_matches_python_legacy_transfer_overwrite() { + let input = r#" +[propagation] +propagation_transfer_max_accepted_size = 12 +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let dc = DaemonConfig::from_config(&config); + + assert_eq!( + dc.propagation_limit_kb, 256, + "DaemonConfig should match Python 0.9.6 handling of legacy propagation_transfer_max_accepted_size" + ); + } + + #[test] + fn test_to_router_config() { + let dc = DaemonConfig::default(); + let rc = dc.to_router_config(); + assert!(!rc.propagation_enabled); + assert_eq!(rc.max_peers, 20); + assert_eq!(rc.delivery_limit_kb, 1000); + assert_eq!(rc.propagation_limit_kb, 256); + assert_eq!(rc.sync_limit_kb, 10_240); + assert_eq!(rc.propagation_stamp_cost, 16); + assert_eq!(rc.propagation_stamp_flex, 3); + assert_eq!(rc.ext.autopeer_maxdepth, AUTOPEER_MAXDEPTH); + assert_eq!(rc.ext.peering_cost, 18); + assert_eq!(rc.ext.max_peering_cost, 26); + assert!(!rc.ext.auth_required); + assert_eq!(rc.ext.message_storage_limit, Some(500_000_000)); + assert_eq!(rc.ext.name, None); + assert!(!rc.ext.from_static_only); + } + + #[test] + fn test_create_router() { + let dc = DaemonConfig::default(); + let router = create_router(&dc); + assert!(router.pending_outbound.is_empty()); + } + + #[test] + fn test_create_router_with_transport() { + let dc = DaemonConfig::default(); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let router = create_router_with_transport(&dc, tx); + assert!(router.has_transport()); + assert!(router.pending_outbound.is_empty()); + } + + #[test] + fn test_parse_config() { + let input = r#" +[lxmf] +display_name = TestNode +announce_at_start = yes +announce_interval = 3 +delivery_transfer_max_accepted_size = 0.1 +stamp_cost = 8 + +[propagation] +enable_node = yes +node_name = PropNode +outbound_node = aabbccddeeff00112233445566778899 +announce_at_start = yes +announce_interval = 2 +message_storage_limit = 0.001 +propagation_message_max_accepted_size = 0.1 +propagation_sync_max_accepted_size = 0.1 +propagation_stamp_cost_target = 1 +propagation_stamp_cost_flexibility = -9 +peering_cost = -1 +remote_peering_cost_max = -2 +max_peers = 10 +autopeer = no +autopeer_maxdepth = 2 +static_peers = 00112233445566778899aabbccddeeff +prioritise_destinations = 0102030405060708090a0b0c0d0e0f10 +control_allowed = 11111111111111111111111111111111 +from_static_only = yes +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let dc = DaemonConfig::from_config(&config); + assert_eq!(dc.display_name.as_deref(), Some("TestNode")); + assert!(dc.announce_at_start); + assert_eq!(dc.announce_interval, Some(180)); + assert_eq!(dc.delivery_transfer_max_accepted_size, 1); + assert_eq!(dc.stamp_cost, Some(8)); + assert!(dc.propagation_enabled); + assert_eq!(dc.node_name.as_deref(), Some("PropNode")); + assert_eq!( + dc.outbound_propagation_node.as_deref(), + Some("aabbccddeeff00112233445566778899") + ); + assert!(dc.node_announce_at_start); + assert_eq!(dc.node_announce_interval, Some(120)); + assert_eq!(dc.message_storage_limit, Some(5_000)); + assert_eq!(dc.propagation_limit_kb, 1); + assert_eq!(dc.sync_limit_kb, 1); + assert_eq!(dc.propagation_stamp_cost, 13); + assert_eq!(dc.propagation_stamp_flex, 0); + assert_eq!(dc.peering_cost, 0); + assert_eq!(dc.max_peering_cost, 0); + assert_eq!(dc.max_peers, 10); + assert!(!dc.autopeer); + assert_eq!(dc.autopeer_maxdepth, 2); + assert_eq!(dc.static_peers, ["00112233445566778899aabbccddeeff"]); + assert_eq!( + dc.prioritise_destinations, + ["0102030405060708090a0b0c0d0e0f10"] + ); + assert_eq!(dc.control_allowed, ["11111111111111111111111111111111"]); + assert!(dc.from_static_only); + } + + #[test] + fn test_parse_python_stamp_target_key_with_floor() { + let input = r#" +[propagation] +propagation_stamp_cost_target = 1 +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let dc = DaemonConfig::from_config(&config); + + assert_eq!(dc.propagation_stamp_cost, PROPAGATION_COST_MIN); + assert_eq!( + dc.to_router_config().propagation_stamp_cost, + PROPAGATION_COST_MIN + ); + } + + #[test] + fn test_legacy_stamp_cost_key_remains_fallback() { + let input = r#" +[propagation] +propagation_stamp_cost = 19 +"#; + let config = rns_runtime::config::Config::parse(input).unwrap(); + let dc = DaemonConfig::from_config(&config); + + assert_eq!(dc.propagation_stamp_cost, 19); + assert_eq!(dc.to_router_config().propagation_stamp_cost, 19); + } +} diff --git a/crates/lxmf-tools/src/lib.rs b/crates/lxmf-tools/src/lib.rs new file mode 100644 index 0000000..03d8b43 --- /dev/null +++ b/crates/lxmf-tools/src/lib.rs @@ -0,0 +1,6 @@ +//! LXMF Tools: shared library code for lxmd and LXMF CLI utilities. + +pub mod daemon; +pub mod lxmd_cli; +pub mod lxmd_control; +pub mod lxmd_runtime; diff --git a/crates/lxmf-tools/src/lxmd_cli.rs b/crates/lxmf-tools/src/lxmd_cli.rs new file mode 100644 index 0000000..77b79a3 --- /dev/null +++ b/crates/lxmf-tools/src/lxmd_cli.rs @@ -0,0 +1,336 @@ +//! `lxmd` CLI parsing and formatting helpers. +//! +//! Keeping these helpers outside the binary entrypoint lets tests exercise +//! parser, formatting, and small data-normalization surfaces without starting +//! the daemon runtime. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use clap::{Parser, ValueEnum}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum SendMethod { + Opportunistic, + Direct, + Propagated, +} + +impl SendMethod { + pub fn delivery_method(self) -> lxmf_core::constants::DeliveryMethod { + match self { + SendMethod::Opportunistic => lxmf_core::constants::DeliveryMethod::Opportunistic, + SendMethod::Direct => lxmf_core::constants::DeliveryMethod::Direct, + SendMethod::Propagated => lxmf_core::constants::DeliveryMethod::Propagated, + } + } +} + +#[derive(Parser)] +#[command( + name = "lxmd-rs", + bin_name = "lxmd-rs", + about = "LXMF Propagation Daemon", + version +)] +pub struct Args { + /// Path to configuration directory. + #[arg(short, long)] + pub config: Option, + + /// Path to alternative Reticulum configuration directory. + #[arg(long)] + pub rnsconfig: Option, + + /// Run an LXMF propagation node, overriding config. + #[arg(short = 'p', long = "propagation-node")] + pub propagation_node: bool, + + /// Executable to run when a message is received, overriding config. + #[arg(short = 'i', long = "on-inbound", value_name = "PATH")] + pub on_inbound: Option, + + /// Increase verbosity (can be repeated). + #[arg(short, long, action = clap::ArgAction::Count)] + pub verbose: u8, + + /// Decrease verbosity (can be repeated). + #[arg(short, long, action = clap::ArgAction::Count)] + pub quiet: u8, + + /// Generate and print example configuration. + #[arg(long)] + pub exampleconfig: bool, + + /// Run as a system service (no interactive output). + #[arg(short = 's', long)] + pub service: bool, + + /// Display local node status and exit. + #[arg(long)] + pub status: bool, + + /// Display known propagation peers and exit. + #[arg(long)] + pub peers: bool, + + /// Request a sync with the specified peer and exit. + #[arg(long, value_name = "PEER_HASH")] + pub sync: Option, + + /// Break peering with the specified peer and exit. + #[arg(short = 'b', long = "break", value_name = "PEER_HASH")] + pub unpeer: Option, + + /// Timeout in seconds for query operations. + #[arg(long)] + pub timeout: Option, + + /// Remote propagation node destination hash for query operations. + #[arg(short = 'r', long, value_name = "DEST_HASH")] + pub remote: Option, + + /// Identity path used for remote query operations. + #[arg(long, value_name = "PATH")] + pub identity: Option, + + /// Send a single message and exit: --send + #[arg(long, num_args = 1..=2, value_names = ["DEST_HASH", "CONTENT"])] + pub send: Option>, + + /// Read outgoing --send content from a UTF-8 file instead of argv. + #[arg(long, value_name = "PATH")] + pub send_file: Option, + + /// Delivery method for --send. + #[arg(long, value_enum, default_value_t = SendMethod::Opportunistic)] + pub send_method: SendMethod, + + /// Link/resource completion timeout for --send. + #[arg(long, default_value_t = 90)] + pub send_timeout_secs: u64, + + /// Attach custom LXMF fields to the outgoing --send message. Accepts a + /// JSON object mapping field-id -> base64(value). Example: + /// --send-fields-json '{"1":"aGVsbG8=","42":"AAECA/8="}' + /// Only meaningful alongside --send. + #[arg(long, value_name = "JSON")] + pub send_fields_json: Option, +} + +pub fn parse_send_fields_json(raw: &str) -> Result>, String> { + use base64::Engine; + let parsed: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("--send-fields-json is not JSON: {e}"))?; + let map = parsed + .as_object() + .ok_or_else(|| "--send-fields-json must be a JSON object".to_string())?; + let mut out = BTreeMap::new(); + let b64 = base64::engine::general_purpose::STANDARD; + for (key, value) in map { + let fid: u8 = key + .parse::() + .ok() + .and_then(|v| u8::try_from(v).ok()) + .ok_or_else(|| format!("field id {key:?} is not a u8"))?; + let s = value + .as_str() + .ok_or_else(|| format!("field {fid} value must be a base64 string"))?; + let bytes = b64 + .decode(s) + .map_err(|e| format!("field {fid} base64 decode failed: {e}"))?; + out.insert(fid, bytes); + } + Ok(out) +} + +pub fn normalize_hash_hex(raw: &str) -> String { + raw.replace(":", "") + .replace(" ", "") + .replace("<", "") + .replace(">", "") +} + +pub fn parse_destination_hash(raw: &str) -> Result<[u8; 16], String> { + let normalized = normalize_hash_hex(raw); + if normalized.len() != 32 { + return Err("destination hash must be 32 hex characters".to_string()); + } + let bytes = hex::decode(&normalized).map_err(|e| format!("invalid destination hash: {e}"))?; + let mut hash = [0u8; 16]; + hash.copy_from_slice(&bytes); + Ok(hash) +} + +pub fn example_config() -> &'static str { + r#"# This is an example LXM Daemon config file. +[propagation] + +enable_node = no + +# control_allowed = 7d7e542829b40f32364499b27438dba8, 437229f8e29598b2282b88bad5e44698 + +# node_name = Anonymous Propagation Node + +announce_interval = 360 + +announce_at_start = yes + +autopeer = yes + +autopeer_maxdepth = 6 + +# message_storage_limit = 500 + +# propagation_message_max_accepted_size = 256 + +# propagation_sync_max_accepted_size = 10240 + +# propagation_stamp_cost_target = 16 + +# propagation_stamp_cost_flexibility = 3 + +# peering_cost = 18 + +# remote_peering_cost_max = 26 + +# max_peers = 20 + +# static_peers = e17f833c4ddf8890dd3a79a6fea8161d, 5a2d0029b6e5ec87020abaea0d746da4 + +# prioritise_destinations = 4a594a8cced4a8f6adf23a8ac67b4011 + +# from_static_only = True + +auth_required = no + + +[lxmf] + +display_name = Anonymous Peer + +announce_at_start = no + +# announce_interval = 360 + +delivery_transfer_max_accepted_size = 1000 + +# on_inbound = /path/to/handler + + +[logging] + +loglevel = 4 +"# +} + +/// Parse a plaintext destination-hash list: one 16-byte hex value per line. +/// Missing files return empty. Like Python `lxmd.py`, this accepts only raw +/// 32-byte hex lines; comments and inline comments are ignored only because +/// their raw line length is not exactly 32 bytes. +/// +/// Python reference: `lxmd.py` reads `ignored` / `allowed` from the config dir. +pub fn load_hash_list(path: &Path) -> Vec<[u8; 16]> { + let contents = match std::fs::read(path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + contents + .split(|b| *b == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) + .filter(|line| line.len() == 32) + .filter_map(|line| { + let hex_str = std::str::from_utf8(line).ok()?; + let bytes = hex::decode(hex_str).ok()?; + bytes.try_into().ok() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn parses_lxmd_utility_flags() { + let args = Args::try_parse_from([ + "lxmd", + "--config", + "/tmp/lxmd", + "--rnsconfig", + "/tmp/rns", + "-p", + "-i", + "/bin/true", + "-s", + "--status", + "--peers", + "--sync", + "00112233445566778899aabbccddeeff", + "-b", + "ffeeddccbbaa99887766554433221100", + "--timeout", + "1.5", + "-r", + "01010101010101010101010101010101", + "--identity", + "/tmp/id", + ]) + .unwrap(); + + assert_eq!(args.config.as_deref(), Some("/tmp/lxmd")); + assert_eq!(args.rnsconfig.as_deref(), Some("/tmp/rns")); + assert!(args.propagation_node); + assert_eq!(args.on_inbound.as_deref(), Some("/bin/true")); + assert!(args.service); + assert!(args.status); + assert!(args.peers); + assert!(args.sync.is_some()); + assert!(args.unpeer.is_some()); + assert_eq!(args.timeout, Some(1.5)); + assert!(args.remote.is_some()); + assert_eq!(args.identity.as_deref(), Some(Path::new("/tmp/id"))); + } + + #[test] + fn parse_destination_hash_accepts_pretty_hex() { + let hash = + parse_destination_hash("<00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff>").unwrap(); + assert_eq!(hex::encode(hash), "00112233445566778899aabbccddeeff"); + } + + #[test] + fn load_hash_list_matches_python_line_length_parser() { + let path = std::env::temp_dir().join(format!( + "lxmd-hash-list-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write( + &path, + b"00112233445566778899aabbccddeeff\n\ + # this full-line comment is ignored by length\n\ + 11111111111111111111111111111111 # inline comments are not stripped\n\ + AABBCCDDEEFF00112233445566778899\n\ + short\n", + ) + .unwrap(); + + let hashes = load_hash_list(&path) + .into_iter() + .map(hex::encode) + .collect::>(); + assert_eq!( + hashes, + [ + "00112233445566778899aabbccddeeff", + "aabbccddeeff00112233445566778899" + ] + ); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/lxmf-tools/src/lxmd_control.rs b/crates/lxmf-tools/src/lxmd_control.rs new file mode 100644 index 0000000..ba1ef1d --- /dev/null +++ b/crates/lxmf-tools/src/lxmd_control.rs @@ -0,0 +1,1035 @@ +//! Python-compatible `lxmd` propagation-control client and output helpers. +//! +//! Python reference: `LXMF/Utilities/lxmd.py` `query_status`, +//! `request_sync`, `request_unpeer`, and `get_status`. + +use std::time::Duration; + +use lxmf_core::constants::PeerError; +use lxmf_core::propagation_node::PropagationNode; +use lxmf_core::router::LxmRouter; +use rmpv::Value; +use rns_identity::identity::Identity; +use rns_runtime::link_client::{LinkClient, LinkClientError}; +use rns_transport::messages::{AnnounceHandlerEvent, TransportMessage}; +use tokio::sync::mpsc; + +pub const CONTROL_APP_NAME: &str = "lxmf.propagation.control"; +pub const PROPAGATION_APP_NAME: &str = "lxmf.propagation"; + +#[derive(Debug)] +pub enum ControlResponse { + Stats(Value), + Success, + Error(PeerError), + Empty, +} + +#[derive(Debug, Clone, Copy)] +pub enum ControlCommandKind { + Status, + Sync, + Unpeer, +} + +impl ControlCommandKind { + pub fn timeout_message(self) -> &'static str { + match self { + Self::Status => "Getting lxmd statistics timed out, exiting now", + Self::Sync => "Requesting lxmd peer sync timed out, exiting now", + Self::Unpeer => "Requesting lxmd peering break timed out, exiting now", + } + } +} + +pub async fn query_control( + transport_tx: tokio::sync::mpsc::Sender, + identity: Identity, + target_identity_hash: [u8; 16], + path: &str, + payload: Vec, + timeout_secs: f64, +) -> Result, LinkClientError> { + let client = LinkClient::new(transport_tx, identity); + client + .query( + target_identity_hash, + CONTROL_APP_NAME, + path, + payload, + 0, + Duration::from_secs_f64(timeout_secs.max(0.0)), + ) + .await +} + +pub async fn resolve_remote_identity_hash( + transport_tx: mpsc::Sender, + remote_destination_hash: [u8; 16], + timeout_secs: f64, +) -> Result<[u8; 16], LinkClientError> { + let (ann_tx, mut ann_rx) = mpsc::channel::(64); + transport_tx + .send(TransportMessage::RegisterAnnounceHandler { + aspect_filter: Some(PROPAGATION_APP_NAME.to_string()), + receive_path_responses: true, + callback_tx: ann_tx, + }) + .await + .map_err(|_| LinkClientError::TransportUnavailable)?; + + let send_result = transport_tx + .send(TransportMessage::RequestPath { + destination_hash: remote_destination_hash, + }) + .await; + if send_result.is_err() { + let _ = transport_tx.try_send(TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(PROPAGATION_APP_NAME.to_string()), + }); + return Err(LinkClientError::TransportUnavailable); + } + + let wait = async { + while let Some(event) = ann_rx.recv().await { + if event.destination_hash == remote_destination_hash + && let Some(identity_hash) = event.identity_hash + { + return Ok(identity_hash); + } + } + Err(LinkClientError::PubkeyNotDiscovered) + }; + + let result = + match tokio::time::timeout(Duration::from_secs_f64(timeout_secs.max(0.0)), wait).await { + Ok(result) => result, + Err(_) => Err(LinkClientError::Timeout("remote identity resolution")), + }; + + let _ = transport_tx.try_send(TransportMessage::DeregisterAnnounceHandler { + aspect_filter: Some(PROPAGATION_APP_NAME.to_string()), + }); + + result +} + +pub fn decode_control_response(response: &[u8]) -> ControlResponse { + if response.is_empty() { + return ControlResponse::Empty; + } + + let Ok(value) = rmpv::decode::read_value(&mut &response[..]) else { + return ControlResponse::Success; + }; + + if let Some(code) = value.as_u64() + && let Some(error) = peer_error_from_code(code as u8) + { + return ControlResponse::Error(error); + } + + if value.is_nil() { + ControlResponse::Empty + } else if value.as_map().is_some() { + ControlResponse::Stats(value) + } else { + ControlResponse::Success + } +} + +pub fn peer_error_from_code(code: u8) -> Option { + match code { + 0xF0 => Some(PeerError::NoIdentity), + 0xF1 => Some(PeerError::NoAccess), + 0xF4 => Some(PeerError::InvalidData), + 0xFD => Some(PeerError::NotFound), + 0xFE => Some(PeerError::Timeout), + _ => None, + } +} + +pub fn encode_peer_error(error: PeerError) -> Vec { + encode_value(&Value::from(error as u64)) +} + +pub fn encode_control_success() -> Vec { + encode_value(&Value::Boolean(true)) +} + +pub fn encode_nil_response() -> Vec { + encode_value(&Value::Nil) +} + +pub fn encode_router_control_stats( + router: &LxmRouter, + identity_hash: [u8; 16], + propagation_destination_hash: [u8; 16], + node: Option<&PropagationNode>, + now: f64, +) -> Vec { + let message_count = node + .map(PropagationNode::message_count) + .unwrap_or_else(|| router.propagation_store.len()); + let message_size = node + .map(PropagationNode::total_size) + .unwrap_or_else(|| router.propagation_store.total_size()); + let storage_limit = router.config.ext.message_storage_limit; + + let mut peer_entries = Vec::new(); + for (hash, peer) in &router.peers { + let peer_type = if router.static_peers.contains(hash) || peer.is_static { + "static" + } else { + "discovered" + }; + let acceptance_rate = if peer.offered == 0 { + 0.0 + } else { + peer.outgoing as f64 / peer.offered as f64 + }; + let peering_key_value = peer + .peering_key + .as_ref() + .map(|(_, value)| Value::from(*value as u64)) + .unwrap_or(Value::Nil); + + let peer_map = Value::Map(vec![ + (Value::String("type".into()), Value::from(peer_type)), + ( + Value::String("state".into()), + Value::from(peer.state as u64), + ), + (Value::String("alive".into()), Value::Boolean(peer.alive)), + (Value::String("name".into()), Value::Nil), + ( + Value::String("last_heard".into()), + Value::from(peer.last_heard as i64), + ), + ( + Value::String("next_sync_attempt".into()), + Value::F64(peer.next_sync_attempt), + ), + ( + Value::String("last_sync_attempt".into()), + Value::F64(peer.last_sync_attempt), + ), + ( + Value::String("sync_backoff".into()), + Value::F64(peer.sync_backoff), + ), + ( + Value::String("peering_timebase".into()), + Value::F64(peer.peering_timebase), + ), + ( + Value::String("ler".into()), + Value::from(peer.link_establishment_rate as i64), + ), + ( + Value::String("str".into()), + Value::from(peer.sync_transfer_rate as i64), + ), + ( + Value::String("transfer_limit".into()), + option_f64(peer.propagation_transfer_limit), + ), + ( + Value::String("sync_limit".into()), + option_f64(peer.propagation_sync_limit), + ), + ( + Value::String("target_stamp_cost".into()), + option_u8(peer.stamp_cost), + ), + ( + Value::String("stamp_cost_flexibility".into()), + option_u8(peer.stamp_cost_flexibility), + ), + ( + Value::String("peering_cost".into()), + Value::from(peer.peering_cost as u64), + ), + (Value::String("peering_key".into()), peering_key_value), + ( + Value::String("network_distance".into()), + Value::from(255_u64), + ), + (Value::String("rx_bytes".into()), Value::from(peer.rx_bytes)), + (Value::String("tx_bytes".into()), Value::from(peer.tx_bytes)), + ( + Value::String("acceptance_rate".into()), + Value::F64(acceptance_rate), + ), + ( + Value::String("messages".into()), + Value::Map(vec![ + (Value::String("offered".into()), Value::from(peer.offered)), + (Value::String("outgoing".into()), Value::from(peer.outgoing)), + (Value::String("incoming".into()), Value::from(peer.incoming)), + ( + Value::String("unhandled".into()), + Value::from(peer.unhandled_messages() as u64), + ), + ]), + ), + ]); + peer_entries.push((Value::Binary(hash.to_vec()), peer_map)); + } + + let static_peers = router + .peers + .keys() + .filter(|hash| router.static_peers.contains(hash)) + .count(); + let discovered_peers = router.peers.len().saturating_sub(static_peers); + let uptime = router + .propagation_start_time + .map(|started| now - started) + .unwrap_or(0.0); + + let stats = Value::Map(vec![ + ( + Value::String("identity_hash".into()), + Value::Binary(identity_hash.to_vec()), + ), + ( + Value::String("destination_hash".into()), + Value::Binary(propagation_destination_hash.to_vec()), + ), + (Value::String("uptime".into()), Value::F64(uptime)), + ( + Value::String("delivery_limit".into()), + Value::from(router.config.delivery_limit_kb as u64), + ), + ( + Value::String("propagation_limit".into()), + Value::from(router.config.propagation_limit_kb as u64), + ), + ( + Value::String("sync_limit".into()), + Value::from(router.config.sync_limit_kb as u64), + ), + ( + Value::String("target_stamp_cost".into()), + Value::from(router.config.propagation_stamp_cost as u64), + ), + ( + Value::String("stamp_cost_flexibility".into()), + Value::from(router.config.propagation_stamp_flex as u64), + ), + ( + Value::String("peering_cost".into()), + Value::from(router.config.ext.peering_cost as u64), + ), + ( + Value::String("max_peering_cost".into()), + Value::from(router.config.ext.max_peering_cost as u64), + ), + ( + Value::String("autopeer_maxdepth".into()), + Value::from(router.config.ext.autopeer_maxdepth as u64), + ), + ( + Value::String("from_static_only".into()), + Value::Boolean(router.config.ext.from_static_only), + ), + ( + Value::String("messagestore".into()), + Value::Map(vec![ + ( + Value::String("count".into()), + Value::from(message_count as u64), + ), + ( + Value::String("bytes".into()), + Value::from(message_size as u64), + ), + ( + Value::String("limit".into()), + storage_limit + .map(|limit| Value::from(limit as u64)) + .unwrap_or(Value::Nil), + ), + ]), + ), + ( + Value::String("clients".into()), + Value::Map(vec![ + ( + Value::String("client_propagation_messages_received".into()), + Value::from(router.client_propagation_messages_received), + ), + ( + Value::String("client_propagation_messages_served".into()), + Value::from(router.client_propagation_messages_served), + ), + ]), + ), + ( + Value::String("unpeered_propagation_incoming".into()), + Value::from(router.unpeered_propagation_incoming), + ), + ( + Value::String("unpeered_propagation_rx_bytes".into()), + Value::from(router.unpeered_propagation_rx_bytes), + ), + ( + Value::String("static_peers".into()), + Value::from(static_peers as u64), + ), + ( + Value::String("discovered_peers".into()), + Value::from(discovered_peers as u64), + ), + ( + Value::String("total_peers".into()), + Value::from(router.peers.len() as u64), + ), + ( + Value::String("max_peers".into()), + Value::from(router.config.max_peers as u64), + ), + (Value::String("peers".into()), Value::Map(peer_entries)), + ]); + + encode_value(&stats) +} + +pub fn print_control_link_error(kind: ControlCommandKind, _error: &LinkClientError) -> ! { + println!("{}", kind.timeout_message()); + std::process::exit(200); +} + +pub fn exit_for_control_response(kind: ControlCommandKind, response: &ControlResponse) -> bool { + match response { + &ControlResponse::Error(PeerError::NoIdentity) => { + println!("Remote received no identity"); + std::process::exit(203) + } + &ControlResponse::Error(PeerError::NoAccess) => { + println!("Access denied"); + std::process::exit(204) + } + &ControlResponse::Error(PeerError::InvalidData) => { + println!("Invalid data received by remote"); + std::process::exit(205) + } + &ControlResponse::Error(PeerError::NotFound) => { + println!("The requested peer was not found"); + std::process::exit(206) + } + &ControlResponse::Error(PeerError::Timeout) => { + println!("{}", kind.timeout_message()); + std::process::exit(200) + } + ControlResponse::Empty => { + println!("Empty response received"); + std::process::exit(207) + } + ControlResponse::Error(_) | ControlResponse::Stats(_) | ControlResponse::Success => false, + } +} + +pub fn format_remote_status( + stats: &Value, + show_status: bool, + show_peers: bool, + now: f64, +) -> String { + let mut out = String::new(); + + let destination_hash = map_bytes(stats, "destination_hash").unwrap_or_default(); + let uptime = map_f64(stats, "uptime").unwrap_or(0.0); + out.push_str(&format!( + "\nLXMF Propagation Node running on {}, uptime is {}\n", + pretty_hex(&destination_hash), + pretty_time(uptime), + )); + + let peers = map_value(stats, "peers"); + let peer_entries = peers + .and_then(Value::as_map) + .map(Vec::as_slice) + .unwrap_or(&[]); + + let mut available_peers = 0_u64; + let mut unreachable_peers = 0_u64; + let mut peered_incoming = 0_u64; + let mut peered_outgoing = 0_u64; + let mut peered_rx_bytes = 0_u64; + let mut peered_tx_bytes = 0_u64; + + for (_, peer) in peer_entries { + let messages = map_value(peer, "messages"); + peered_incoming += messages.and_then(|m| map_u64(m, "incoming")).unwrap_or(0); + peered_outgoing += messages.and_then(|m| map_u64(m, "outgoing")).unwrap_or(0); + peered_rx_bytes += map_u64(peer, "rx_bytes").unwrap_or(0); + peered_tx_bytes += map_u64(peer, "tx_bytes").unwrap_or(0); + if map_bool(peer, "alive").unwrap_or(false) { + available_peers += 1; + } else { + unreachable_peers += 1; + } + } + + let clients = map_value(stats, "clients"); + let client_received = clients + .and_then(|c| map_u64(c, "client_propagation_messages_received")) + .unwrap_or(0); + let client_served = clients + .and_then(|c| map_u64(c, "client_propagation_messages_served")) + .unwrap_or(0); + let unpeered_incoming = map_u64(stats, "unpeered_propagation_incoming").unwrap_or(0); + let unpeered_rx_bytes = map_u64(stats, "unpeered_propagation_rx_bytes").unwrap_or(0); + + let total_incoming = peered_incoming + unpeered_incoming + client_received; + let total_rx_bytes = peered_rx_bytes + unpeered_rx_bytes; + let distribution_factor = if total_incoming != 0 { + round2(peered_outgoing as f64 / total_incoming as f64) + } else { + 0.0 + }; + + if show_status { + let messagestore = map_value(stats, "messagestore"); + let store_count = messagestore.and_then(|m| map_u64(m, "count")).unwrap_or(0); + let store_bytes = messagestore.and_then(|m| map_u64(m, "bytes")).unwrap_or(0); + let store_limit = messagestore.and_then(|m| map_u64(m, "limit")).unwrap_or(0); + let store_util = if store_limit == 0 { + 0.0 + } else { + round2((store_bytes as f64 / store_limit as f64) * 100.0) + }; + let who = if map_bool(stats, "from_static_only").unwrap_or(false) { + "static peers only" + } else { + "all nodes" + }; + + out.push_str(&format!( + "Messagestore contains {store_count} messages, {} ({}% utilised of {})\n", + pretty_size(store_bytes as f64, "B"), + format_python_float(store_util), + pretty_size(store_limit as f64, "B"), + )); + out.push_str(&format!( + "Required propagation stamp cost is {}, flexibility is {}\n", + map_u64(stats, "target_stamp_cost").unwrap_or(0), + map_u64(stats, "stamp_cost_flexibility").unwrap_or(0), + )); + out.push_str(&format!( + "Peering cost is {}, max remote peering cost is {}\n", + map_u64(stats, "peering_cost").unwrap_or(0), + map_u64(stats, "max_peering_cost").unwrap_or(0), + )); + out.push_str(&format!("Accepting propagated messages from {who}\n")); + out.push_str(&format!( + "{} message limit, {} sync limit\n\n", + pretty_size( + map_f64(stats, "propagation_limit").unwrap_or(0.0) * 1000.0, + "B" + ), + pretty_size(map_f64(stats, "sync_limit").unwrap_or(0.0) * 1000.0, "B"), + )); + out.push_str(&format!( + "Peers : {} total (peer limit is {})\n", + map_u64(stats, "total_peers").unwrap_or(peer_entries.len() as u64), + map_display(stats, "max_peers"), + )); + out.push_str(&format!( + " {} discovered, {} static\n", + map_u64(stats, "discovered_peers").unwrap_or(0), + map_u64(stats, "static_peers").unwrap_or(0), + )); + out.push_str(&format!( + " {available_peers} available, {unreachable_peers} unreachable\n\n", + )); + out.push_str(&format!( + "Traffic : {total_incoming} messages received in total ({})\n", + pretty_size(total_rx_bytes as f64, "B"), + )); + out.push_str(&format!( + " {peered_incoming} messages received from peered nodes ({})\n", + pretty_size(peered_rx_bytes as f64, "B"), + )); + out.push_str(&format!( + " {unpeered_incoming} messages received from unpeered nodes ({})\n", + pretty_size(unpeered_rx_bytes as f64, "B"), + )); + out.push_str(&format!( + " {peered_outgoing} messages transferred to peered nodes ({})\n", + pretty_size(peered_tx_bytes as f64, "B"), + )); + out.push_str(&format!( + " {client_received} propagation messages received directly from clients\n", + )); + out.push_str(&format!( + " {client_served} propagation messages served to clients\n", + )); + out.push_str(&format!( + " Distribution factor is {}\n\n", + format_python_float(distribution_factor), + )); + } + + if show_peers { + if !show_status { + out.push('\n'); + } + + for (peer_id, peer) in peer_entries { + let peer_hash = peer_id.as_slice().unwrap_or(&[]); + let peer_type = match map_str(peer, "type").unwrap_or("unknown") { + "static" => "Static peer ", + "discovered" => "Discovered peer ", + _ => "Unknown peer ", + }; + let alive = if map_bool(peer, "alive").unwrap_or(false) { + "Available" + } else { + "Unreachable" + }; + let last_heard_age = (now - map_f64(peer, "last_heard").unwrap_or(0.0)).max(0.0); + let hops = map_i64(peer, "network_distance").unwrap_or(255); + let hops_text = if hops == 255 { + "hops unknown".to_string() + } else if hops == 1 { + "1 hop away".to_string() + } else { + format!("{hops} hops away") + }; + let messages = map_value(peer, "messages"); + let peering_key = match map_value(peer, "peering_key") { + Some(value) if !value.is_nil() => { + format!("Generated, value is {}", value_to_display(value)) + } + _ => "Not generated".to_string(), + }; + let last_sync = match map_f64(peer, "last_sync_attempt").unwrap_or(0.0) { + value if value != 0.0 => { + format!("last synced {} ago", pretty_time((now - value).max(0.0))) + } + _ => "never synced".to_string(), + }; + let name = map_str(peer, "name") + .unwrap_or("") + .trim() + .replace(['\n', '\r'], ""); + let display_name = if name.len() > 45 { + format!("{}...", &name[..45]) + } else { + name + }; + let acceptance_rate = round2(map_f64(peer, "acceptance_rate").unwrap_or(0.0) * 100.0); + let unhandled = messages.and_then(|m| map_u64(m, "unhandled")).unwrap_or(0); + let plural = if unhandled == 1 { "" } else { "s" }; + + out.push_str(&format!(" {peer_type}{}\n", pretty_hex(peer_hash))); + if !display_name.is_empty() { + out.push_str(&format!(" Name : {display_name}\n")); + } + out.push_str(&format!( + " Status : {alive}, {hops_text}, last heard {} ago\n", + pretty_time(last_heard_age), + )); + out.push_str(&format!( + " Costs : Propagation {} (flex {}), peering {}\n", + map_optional_display(peer, "target_stamp_cost"), + map_optional_display(peer, "stamp_cost_flexibility"), + map_optional_display(peer, "peering_cost"), + )); + out.push_str(&format!(" Sync key : {peering_key}\n")); + out.push_str(&format!( + " Speeds : {} STR, {} LER\n", + pretty_speed(map_f64(peer, "str").unwrap_or(0.0)), + pretty_speed(map_f64(peer, "ler").unwrap_or(0.0)), + )); + out.push_str(&format!( + " Limits : {} message limit, {} sync limit\n", + optional_size_kb(peer, "transfer_limit", "Unknown"), + optional_size_kb(peer, "sync_limit", "unknown"), + )); + out.push_str(&format!( + " Messages : {} offered, {} outgoing, {} incoming, {}% acceptance rate\n", + messages.and_then(|m| map_u64(m, "offered")).unwrap_or(0), + messages.and_then(|m| map_u64(m, "outgoing")).unwrap_or(0), + messages.and_then(|m| map_u64(m, "incoming")).unwrap_or(0), + format_python_float(acceptance_rate), + )); + out.push_str(&format!( + " Traffic : {} received, {} sent\n", + pretty_size(map_f64(peer, "rx_bytes").unwrap_or(0.0), "B"), + pretty_size(map_f64(peer, "tx_bytes").unwrap_or(0.0), "B"), + )); + out.push_str(&format!( + " Sync state : {unhandled} unhandled message{plural}, {last_sync}\n\n", + )); + } + } + + out +} + +fn map_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_map()?.iter().find_map(|(k, v)| { + if k.as_str() == Some(key) { + Some(v) + } else { + None + } + }) +} + +fn map_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + map_value(value, key)?.as_str() +} + +fn map_bytes(value: &Value, key: &str) -> Option> { + map_value(value, key)?.as_slice().map(|b| b.to_vec()) +} + +fn map_bool(value: &Value, key: &str) -> Option { + map_value(value, key)?.as_bool() +} + +fn map_u64(value: &Value, key: &str) -> Option { + let value = map_value(value, key)?; + value + .as_u64() + .or_else(|| value.as_i64().and_then(|i| u64::try_from(i).ok())) + .or_else(|| value.as_f64().map(|f| f as u64)) +} + +fn map_i64(value: &Value, key: &str) -> Option { + let value = map_value(value, key)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|u| i64::try_from(u).ok())) + .or_else(|| value.as_f64().map(|f| f as i64)) +} + +fn map_f64(value: &Value, key: &str) -> Option { + let value = map_value(value, key)?; + value + .as_f64() + .or_else(|| value.as_u64().map(|u| u as f64)) + .or_else(|| value.as_i64().map(|i| i as f64)) +} + +fn map_display(value: &Value, key: &str) -> String { + map_value(value, key) + .map(value_to_display) + .unwrap_or_else(|| "None".to_string()) +} + +fn map_optional_display(value: &Value, key: &str) -> String { + match map_value(value, key) { + Some(v) if !v.is_nil() => value_to_display(v), + _ => "unknown".to_string(), + } +} + +fn value_to_display(value: &Value) -> String { + if value.is_nil() { + "None".to_string() + } else if let Some(v) = value.as_str() { + v.to_string() + } else if let Some(v) = value.as_u64() { + v.to_string() + } else if let Some(v) = value.as_i64() { + v.to_string() + } else if let Some(v) = value.as_f64() { + format_python_float(v) + } else if let Some(v) = value.as_bool() { + v.to_string() + } else if let Some(v) = value.as_slice() { + hex::encode(v) + } else { + format!("{value:?}") + } +} + +fn optional_size_kb(value: &Value, key: &str, none_text: &str) -> String { + match map_f64(value, key) { + Some(v) if v != 0.0 => pretty_size(v * 1000.0, "B"), + _ => none_text.to_string(), + } +} + +fn pretty_hex(data: &[u8]) -> String { + format!("<{}>", hex::encode(data)) +} + +fn pretty_speed(bits_per_second: f64) -> String { + pretty_size(bits_per_second / 8.0, "b") + "ps" +} + +fn pretty_size(mut num: f64, suffix: &str) -> String { + let units = ["", "K", "M", "G", "T", "P", "E", "Z"]; + let mut last_unit = "Y"; + + if suffix == "b" { + num *= 8.0; + last_unit = "Y"; + } + + for unit in units { + if num.abs() < 1000.0 { + if unit.is_empty() { + return format!("{num:.0} {unit}{suffix}"); + } + return format!("{num:.2} {unit}{suffix}"); + } + num /= 1000.0; + } + + format!("{num:.2}{last_unit}{suffix}") +} + +fn pretty_time(mut seconds: f64) -> String { + let negative = seconds < 0.0; + if negative { + seconds = seconds.abs(); + } + + let days = (seconds / 86_400.0).floor() as u64; + seconds %= 86_400.0; + let hours = (seconds / 3_600.0).floor() as u64; + seconds %= 3_600.0; + let minutes = (seconds / 60.0).floor() as u64; + seconds %= 60.0; + let seconds = round2(seconds); + + let mut components = Vec::new(); + if days > 0 { + components.push(format!("{days}d")); + } + if hours > 0 { + components.push(format!("{hours}h")); + } + if minutes > 0 { + components.push(format!("{minutes}m")); + } + if seconds > 0.0 { + components.push(format!("{}s", format_python_float(seconds))); + } + + let rendered = match components.len() { + 0 => "0s".to_string(), + 1 => components[0].clone(), + _ => { + let last = components.pop().unwrap(); + format!("{} and {last}", components.join(", ")) + } + }; + + if negative { + format!("-{rendered}") + } else { + rendered + } +} + +fn round2(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +fn format_python_float(value: f64) -> String { + if value.fract() == 0.0 { + format!("{value:.0}") + } else { + let mut out = format!("{value:.2}"); + while out.ends_with('0') { + out.pop(); + } + if out.ends_with('.') { + out.pop(); + } + out + } +} + +fn option_u8(value: Option) -> Value { + value.map(|v| Value::from(v as u64)).unwrap_or(Value::Nil) +} + +fn option_f64(value: Option) -> Value { + value.map(Value::F64).unwrap_or(Value::Nil) +} + +fn encode_value(value: &Value) -> Vec { + let mut encoded = Vec::new(); + rmpv::encode::write_value(&mut encoded, value).expect("msgpack value should encode"); + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_value(data: &[u8]) -> Value { + rmpv::decode::read_value(&mut &data[..]).unwrap() + } + + #[test] + fn decodes_python_peer_error_constants() { + let err = decode_value(&[0xcc, 0xf0]); + let mut data = Vec::new(); + rmpv::encode::write_value(&mut data, &err).unwrap(); + assert!(matches!( + decode_control_response(&data), + ControlResponse::Error(PeerError::NoIdentity) + )); + } + + #[test] + fn status_formatter_matches_python_key_lines() { + let value = rmpv::Value::Map(vec![ + ( + Value::String("destination_hash".into()), + Value::Binary(vec![0x01; 16]), + ), + (Value::String("uptime".into()), Value::from(65)), + (Value::String("propagation_limit".into()), Value::from(256)), + (Value::String("sync_limit".into()), Value::from(10240)), + (Value::String("target_stamp_cost".into()), Value::from(16)), + ( + Value::String("stamp_cost_flexibility".into()), + Value::from(3), + ), + (Value::String("peering_cost".into()), Value::from(18)), + (Value::String("max_peering_cost".into()), Value::from(26)), + ( + Value::String("from_static_only".into()), + Value::Boolean(false), + ), + ( + Value::String("messagestore".into()), + Value::Map(vec![ + (Value::String("count".into()), Value::from(2)), + (Value::String("bytes".into()), Value::from(2048)), + (Value::String("limit".into()), Value::from(1_000_000)), + ]), + ), + ( + Value::String("clients".into()), + Value::Map(vec![ + ( + Value::String("client_propagation_messages_received".into()), + Value::from(3), + ), + ( + Value::String("client_propagation_messages_served".into()), + Value::from(4), + ), + ]), + ), + ( + Value::String("unpeered_propagation_incoming".into()), + Value::from(5), + ), + ( + Value::String("unpeered_propagation_rx_bytes".into()), + Value::from(6000), + ), + (Value::String("static_peers".into()), Value::from(0)), + (Value::String("discovered_peers".into()), Value::from(0)), + (Value::String("total_peers".into()), Value::from(0)), + (Value::String("max_peers".into()), Value::from(20)), + (Value::String("peers".into()), Value::Map(vec![])), + ]); + + let out = format_remote_status(&value, true, true, 1_700_000_000.0); + assert!( + out.contains("LXMF Propagation Node running on <01010101010101010101010101010101>") + ); + assert!(out.contains("Messagestore contains 2 messages")); + assert!(out.contains("Required propagation stamp cost is 16, flexibility is 3")); + assert!(out.contains("Peering cost is 18, max remote peering cost is 26")); + assert!(out.contains("Accepting propagated messages from all nodes")); + assert!(out.contains("256.00 KB message limit, 10.24 MB sync limit")); + assert!(out.contains("Peers : 0 total (peer limit is 20)")); + assert!(out.contains("Traffic : 8 messages received in total")); + assert!(out.contains("3 propagation messages received directly from clients")); + assert!(out.contains("4 propagation messages served to clients")); + } + + #[test] + fn stats_encoder_uses_python_compile_stats_shape() { + use lxmf_core::peer::LxmPeer; + use lxmf_core::router::{LxmRouter, RouterConfig}; + + let mut config = RouterConfig { + propagation_enabled: true, + ..Default::default() + }; + config.ext.message_storage_limit = Some(500_000_000); + let mut router = LxmRouter::new(config); + router.propagation_start_time = Some(1_700_000_000.0); + router.client_propagation_messages_received = 2; + router.client_propagation_messages_served = 3; + router.unpeered_propagation_incoming = 4; + router.unpeered_propagation_rx_bytes = 2048; + let peer_hash = [0xAA; 16]; + let mut peer = LxmPeer::new(peer_hash); + peer.offered = 4; + peer.outgoing = 2; + peer.set_unhandled_count(1); + router.peers.insert(peer_hash, peer); + router.static_peers.push(peer_hash); + + let encoded = + encode_router_control_stats(&router, [0x11; 16], [0x22; 16], None, 1_700_003_600.0); + let stats = decode_value(&encoded); + let keys = stats + .as_map() + .expect("stats map") + .iter() + .map(|(key, _)| key.as_str().expect("string key")) + .collect::>(); + assert_eq!( + keys, + [ + "identity_hash", + "destination_hash", + "uptime", + "delivery_limit", + "propagation_limit", + "sync_limit", + "target_stamp_cost", + "stamp_cost_flexibility", + "peering_cost", + "max_peering_cost", + "autopeer_maxdepth", + "from_static_only", + "messagestore", + "clients", + "unpeered_propagation_incoming", + "unpeered_propagation_rx_bytes", + "static_peers", + "discovered_peers", + "total_peers", + "max_peers", + "peers", + ] + ); + assert_eq!(map_bytes(&stats, "identity_hash").unwrap(), vec![0x11; 16]); + assert_eq!( + map_bytes(&stats, "destination_hash").unwrap(), + vec![0x22; 16] + ); + assert_eq!(map_u64(&stats, "static_peers"), Some(1)); + assert_eq!(map_u64(&stats, "discovered_peers"), Some(0)); + assert_eq!(map_u64(&stats, "total_peers"), Some(1)); + + let peers = map_value(&stats, "peers").unwrap().as_map().unwrap(); + let peer_stats = &peers[0].1; + assert_eq!(map_str(peer_stats, "type"), Some("static")); + assert_eq!( + map_value(peer_stats, "messages").and_then(|messages| map_u64(messages, "unhandled")), + Some(1) + ); + assert_eq!(map_f64(peer_stats, "acceptance_rate"), Some(0.5)); + } +} diff --git a/crates/lxmf-tools/src/lxmd_runtime.rs b/crates/lxmf-tools/src/lxmd_runtime.rs new file mode 100644 index 0000000..6568c5e --- /dev/null +++ b/crates/lxmf-tools/src/lxmd_runtime.rs @@ -0,0 +1,564 @@ +//! Pure `lxmd` runtime helpers extracted from the binary. +//! +//! This module keeps daemon path handling and other pure helpers out of the +//! binary so CLI behavior can be tested directly. + +use std::path::{Path, PathBuf}; + +use lxmf_core::router::RouterStats; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LxmdPaths { + pub config_dir: PathBuf, + pub identity_path: PathBuf, + pub storage_dir: PathBuf, + pub messages_dir: PathBuf, + pub lxmf_storage_dir: PathBuf, + pub router_state_dir: PathBuf, + pub propagation_store_dir: PathBuf, + pub ratchets_dir: PathBuf, + pub ratchet_ring_path: PathBuf, + pub received_ratchets_dir: PathBuf, + pub known_identities_path: PathBuf, + pub legacy_lxmf_dir: PathBuf, + pub legacy_identity_path: PathBuf, + pub legacy_messages_dir: PathBuf, + pub legacy_ratchets_dir: PathBuf, + pub legacy_propagation_store_dir: PathBuf, +} + +impl LxmdPaths { + pub fn new(config_dir: impl Into) -> Self { + let config_dir = config_dir.into(); + let identity_path = config_dir.join("identity"); + let storage_dir = config_dir.join("storage"); + let messages_dir = storage_dir.join("messages"); + let lxmf_storage_dir = storage_dir.join("lxmf"); + let router_state_dir = lxmf_storage_dir.clone(); + let propagation_store_dir = lxmf_storage_dir.join("messagestore"); + let ratchets_dir = lxmf_storage_dir.join("ratchets"); + let ratchet_ring_path = ratchets_dir.join("ring"); + let received_ratchets_dir = ratchets_dir.join("received"); + let known_identities_path = ratchets_dir.join("known_identities"); + + let legacy_lxmf_dir = config_dir.join(".lxmf"); + let legacy_identity_path = legacy_lxmf_dir.join("identity"); + let legacy_messages_dir = legacy_lxmf_dir.join("messages"); + let legacy_ratchets_dir = legacy_lxmf_dir.join("ratchets"); + let legacy_propagation_store_dir = legacy_lxmf_dir.join("propagation"); + + Self { + config_dir, + identity_path, + storage_dir, + messages_dir, + lxmf_storage_dir, + router_state_dir, + propagation_store_dir, + ratchets_dir, + ratchet_ring_path, + received_ratchets_dir, + known_identities_path, + legacy_lxmf_dir, + legacy_identity_path, + legacy_messages_dir, + legacy_ratchets_dir, + legacy_propagation_store_dir, + } + } + + pub fn preferred_identity_path(&self) -> &Path { + if self.identity_path.exists() || !self.legacy_identity_path.exists() { + &self.identity_path + } else { + &self.legacy_identity_path + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalStatusView { + pub lxmf_dest_hash: [u8; 16], + pub propagation_enabled: bool, + pub peers: usize, + pub propagation_entries: usize, + pub propagation_size: usize, + pub pending_outbound: usize, + pub pending_deferred_stamps: usize, + pub stamp_costs_cached: usize, +} + +impl LocalStatusView { + pub fn from_router_stats( + lxmf_dest_hash: [u8; 16], + propagation_enabled: bool, + stats: &RouterStats, + ) -> Self { + Self { + lxmf_dest_hash, + propagation_enabled, + peers: stats.peers, + propagation_entries: stats.propagation_entries, + propagation_size: stats.propagation_size, + pending_outbound: stats.pending_outbound, + pending_deferred_stamps: stats.pending_deferred_stamps, + stamp_costs_cached: stats.stamp_costs_cached, + } + } +} + +pub fn format_local_status(status: &LocalStatusView) -> String { + format!( + "LXMF destination: {}\n\ + Propagation node: {}\n\ + Peers: {}\n\ + Propagation messages: {}\n\ + Propagation storage bytes: {}\n\ + Pending outbound: {}\n\ + Pending deferred stamps: {}\n\ + Cached stamp costs: {}\n", + hex::encode(status.lxmf_dest_hash), + if status.propagation_enabled { + "enabled" + } else { + "disabled" + }, + status.peers, + status.propagation_entries, + status.propagation_size, + status.pending_outbound, + status.pending_deferred_stamps, + status.stamp_costs_cached, + ) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LocalPeerView { + pub hash: [u8; 16], + pub state: u8, + pub alive: bool, + pub unhandled: u32, + pub last_heard: f64, +} + +pub fn format_local_peers(peers: &[LocalPeerView]) -> String { + if peers.is_empty() { + return "No peers\n".to_string(); + } + + let mut out = String::new(); + for peer in peers { + out.push_str(&format!( + "{} state={} alive={} unhandled={} last_heard={:.0}\n", + hex::encode(peer.hash), + peer.state, + peer.alive, + peer.unhandled, + peer.last_heard, + )); + } + out +} + +pub fn delivery_announce_app_data(display_name: Option<&str>, stamp_cost: Option) -> Vec { + lxmf_core::handlers::get_announce_app_data(display_name, stamp_cost) +} + +pub fn propagation_announce_app_data( + data: &lxmf_core::handlers::PropagationNodeAnnounceData, +) -> Vec { + lxmf_core::handlers::get_propagation_node_app_data(data) +} + +pub fn resolve_config_dirs(config: Option<&str>, rnsconfig: Option<&str>) -> (PathBuf, PathBuf) { + let config_dir = match config { + Some(dir) => PathBuf::from(dir), + None => default_lxmd_config_dir(), + }; + let rns_config_dir = match rnsconfig { + Some(dir) => rns_runtime::platform::resolve_config_dir(Some(dir)), + None => rns_runtime::platform::resolve_config_dir(None), + }; + (config_dir, rns_config_dir) +} + +fn default_lxmd_config_dir() -> PathBuf { + if cfg!(target_os = "windows") { + return std::env::var_os("APPDATA") + .map(PathBuf::from) + .map(|path| path.join("rsLXMF")) + .unwrap_or_else(|| PathBuf::from(".rsLXMF")); + } + + if cfg!(target_os = "android") { + return PathBuf::from("/data/local/tmp/.rsLXMF"); + } + + let etc = PathBuf::from("/etc/rsLXMF"); + if etc.join("config").is_file() { + return etc; + } + + if let Ok(home) = std::env::var("HOME") { + let xdg = PathBuf::from(&home).join(".config/rsLXMF"); + if xdg.join("config").is_file() { + return xdg; + } + PathBuf::from(home).join(".rsLXMF") + } else { + PathBuf::from(".rsLXMF") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlPreflight { + pub peer_hash: Option<[u8; 16]>, + pub remote_hash: Option<[u8; 16]>, + pub identity_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlPreflightError { + pub exit_code: i32, + pub message: String, +} + +fn parse_python_control_hash(raw: &str, label: &str) -> Result<[u8; 16], ControlPreflightError> { + let bytes = hex::decode(raw).map_err(|e| ControlPreflightError { + exit_code: 203, + message: format!("Invalid {label} destination hash: {e}"), + })?; + if bytes.len() != 16 { + return Err(ControlPreflightError { + exit_code: 203, + message: format!( + "Invalid {label} destination hash: Destination hash length must be 32 characters" + ), + }); + } + + let mut hash = [0u8; 16]; + hash.copy_from_slice(&bytes); + Ok(hash) +} + +pub fn preflight_control_command( + config_dir: &Path, + identity_path: Option<&Path>, + peer_hash: Option<&str>, + remote_hash: Option<&str>, +) -> Result { + let peer_hash = match peer_hash { + Some(raw) => Some(parse_python_control_hash(raw, "peer")?), + None => None, + }; + + let identity_path = if let Some(path) = identity_path { + path.to_path_buf() + } else { + if !config_dir.is_dir() { + return Err(ControlPreflightError { + exit_code: 201, + message: "Specified configuration directory does not exist, exiting now" + .to_string(), + }); + } + config_dir.join("identity") + }; + + if !identity_path.is_file() { + return Err(ControlPreflightError { + exit_code: 202, + message: "Identity file not found in specified configuration directory, exiting now" + .to_string(), + }); + } + + let remote_hash = match remote_hash { + Some(raw) => Some(parse_python_control_hash(raw, "remote")?), + None => None, + }; + + Ok(ControlPreflight { + peer_hash, + remote_hash, + identity_path, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_dir(name: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("lxmd-{name}-{}-{unique}", std::process::id())) + } + + #[test] + fn lxmd_paths_match_python_storage_layout() { + let config = PathBuf::from("/tmp/lxmd-config"); + let paths = LxmdPaths::new(&config); + + assert_eq!(paths.config_dir, config); + assert_eq!( + paths.identity_path, + PathBuf::from("/tmp/lxmd-config/identity") + ); + assert_eq!(paths.storage_dir, PathBuf::from("/tmp/lxmd-config/storage")); + assert_eq!( + paths.messages_dir, + PathBuf::from("/tmp/lxmd-config/storage/messages") + ); + assert_eq!( + paths.lxmf_storage_dir, + PathBuf::from("/tmp/lxmd-config/storage/lxmf") + ); + assert_eq!( + paths.router_state_dir, + PathBuf::from("/tmp/lxmd-config/storage/lxmf") + ); + assert_eq!( + paths.propagation_store_dir, + PathBuf::from("/tmp/lxmd-config/storage/lxmf/messagestore") + ); + assert_eq!( + paths.ratchets_dir, + PathBuf::from("/tmp/lxmd-config/storage/lxmf/ratchets") + ); + assert_eq!( + paths.ratchet_ring_path, + PathBuf::from("/tmp/lxmd-config/storage/lxmf/ratchets/ring") + ); + assert_eq!( + paths.received_ratchets_dir, + PathBuf::from("/tmp/lxmd-config/storage/lxmf/ratchets/received") + ); + assert_eq!( + paths.known_identities_path, + PathBuf::from("/tmp/lxmd-config/storage/lxmf/ratchets/known_identities") + ); + } + + #[test] + fn lxmd_paths_expose_legacy_rust_layout() { + let paths = LxmdPaths::new("/tmp/lxmd-config"); + + assert_eq!( + paths.legacy_lxmf_dir, + PathBuf::from("/tmp/lxmd-config/.lxmf") + ); + assert_eq!( + paths.legacy_identity_path, + PathBuf::from("/tmp/lxmd-config/.lxmf/identity") + ); + assert_eq!( + paths.legacy_messages_dir, + PathBuf::from("/tmp/lxmd-config/.lxmf/messages") + ); + assert_eq!( + paths.legacy_ratchets_dir, + PathBuf::from("/tmp/lxmd-config/.lxmf/ratchets") + ); + assert_eq!( + paths.legacy_propagation_store_dir, + PathBuf::from("/tmp/lxmd-config/.lxmf/propagation") + ); + } + + #[test] + fn preferred_identity_path_uses_python_identity_first() { + let temp = unique_temp_dir("identity-python-first"); + let paths = LxmdPaths::new(&temp); + std::fs::create_dir_all(paths.legacy_lxmf_dir.clone()).unwrap(); + std::fs::write(&paths.identity_path, b"python").unwrap(); + std::fs::write(&paths.legacy_identity_path, b"legacy").unwrap(); + + assert_eq!(paths.preferred_identity_path(), paths.identity_path); + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn preferred_identity_path_falls_back_to_legacy_identity() { + let temp = unique_temp_dir("identity-legacy-fallback"); + let paths = LxmdPaths::new(&temp); + std::fs::create_dir_all(paths.legacy_lxmf_dir.clone()).unwrap(); + std::fs::write(&paths.legacy_identity_path, b"legacy").unwrap(); + + assert_eq!(paths.preferred_identity_path(), paths.legacy_identity_path); + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn preferred_identity_path_defaults_to_python_identity_for_fresh_config() { + let temp = unique_temp_dir("identity-fresh"); + let paths = LxmdPaths::new(&temp); + + assert_eq!(paths.preferred_identity_path(), paths.identity_path); + } + + #[test] + fn local_status_format_matches_current_cli_output() { + let status = LocalStatusView { + lxmf_dest_hash: [0x11; 16], + propagation_enabled: false, + peers: 2, + propagation_entries: 3, + propagation_size: 4096, + pending_outbound: 4, + pending_deferred_stamps: 5, + stamp_costs_cached: 6, + }; + + assert_eq!( + format_local_status(&status), + "LXMF destination: 11111111111111111111111111111111\n\ + Propagation node: disabled\n\ + Peers: 2\n\ + Propagation messages: 3\n\ + Propagation storage bytes: 4096\n\ + Pending outbound: 4\n\ + Pending deferred stamps: 5\n\ + Cached stamp costs: 6\n" + ); + } + + #[test] + fn local_peers_format_matches_current_cli_output() { + assert_eq!(format_local_peers(&[]), "No peers\n"); + + let peers = [LocalPeerView { + hash: [0x22; 16], + state: 1, + alive: true, + unhandled: 7, + last_heard: 12.3, + }]; + assert_eq!( + format_local_peers(&peers), + "22222222222222222222222222222222 state=1 alive=true unhandled=7 last_heard=12\n" + ); + } + + #[test] + fn delivery_announce_app_data_matches_python_msgpack() { + assert_eq!( + hex::encode(delivery_announce_app_data(Some("Test"), Some(16))), + "92c4045465737410" + ); + assert_eq!( + hex::encode(delivery_announce_app_data(None, None)), + "92c0c0" + ); + } + + #[test] + fn propagation_announce_app_data_matches_python_msgpack() { + let mut data = + lxmf_core::handlers::PropagationNodeAnnounceData::new(true, 256, 10_240, 16, 3, 18); + data.timebase = 1_700_000_000; + data.set_name("Node"); + + assert_eq!( + hex::encode(propagation_announce_app_data(&data)), + "97c2ce6553f100c3cd0100cd2800931003128101c4044e6f6465" + ); + } + + #[test] + fn explicit_rnsconfig_overrides_lxmd_config_dir() { + let (config, rnsconfig) = resolve_config_dirs(Some("/tmp/lxmd-a"), Some("/tmp/rns-a")); + assert_eq!(config, PathBuf::from("/tmp/lxmd-a")); + assert_eq!(rnsconfig, PathBuf::from("/tmp/rns-a")); + + let (config, rnsconfig) = resolve_config_dirs(Some("/tmp/lxmd-b"), None); + assert_eq!(config, PathBuf::from("/tmp/lxmd-b")); + assert_ne!(rnsconfig, PathBuf::from("/tmp/lxmd-b")); + assert!( + rnsconfig.ends_with("rsReticulum") + || rnsconfig.ends_with(".rsReticulum") + || rnsconfig.ends_with("/data/local/tmp/.rsReticulum") + ); + } + + #[test] + fn omitted_config_uses_rslxmf_defaults() { + let (config, rnsconfig) = resolve_config_dirs(None, None); + assert!( + config.ends_with("rsLXMF") + || config.ends_with(".rsLXMF") + || config.ends_with("/data/local/tmp/.rsLXMF") + ); + assert!( + rnsconfig.ends_with("rsReticulum") + || rnsconfig.ends_with(".rsReticulum") + || rnsconfig.ends_with("/data/local/tmp/.rsReticulum") + ); + assert_ne!(config, rnsconfig); + } + + #[test] + fn control_preflight_matches_python_non_network_exit_order() { + let missing_dir = std::env::temp_dir().join(format!( + "lxmd-control-preflight-missing-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&missing_dir); + let invalid_peer = + preflight_control_command(&missing_dir, None, Some("zz"), Some("also-invalid")) + .unwrap_err(); + assert_eq!(invalid_peer.exit_code, 203); + assert!( + invalid_peer + .message + .contains("Invalid peer destination hash") + ); + + let missing_config = + preflight_control_command(&missing_dir, None, None, Some("zz")).unwrap_err(); + assert_eq!(missing_config.exit_code, 201); + assert!( + missing_config + .message + .contains("Specified configuration directory does not exist") + ); + + let temp = + std::env::temp_dir().join(format!("lxmd-control-preflight-{}", std::process::id())); + std::fs::create_dir_all(&temp).unwrap(); + let missing_identity = + preflight_control_command(&temp, None, None, Some("zz")).unwrap_err(); + assert_eq!(missing_identity.exit_code, 202); + + let identity = temp.join("identity"); + std::fs::write(&identity, b"identity").unwrap(); + let invalid_remote = preflight_control_command(&temp, None, None, Some("zz")).unwrap_err(); + assert_eq!(invalid_remote.exit_code, 203); + assert!( + invalid_remote + .message + .contains("Invalid remote destination hash") + ); + + let ok = preflight_control_command( + &temp, + None, + Some("00112233445566778899aabbccddeeff"), + Some("11111111111111111111111111111111"), + ) + .unwrap(); + assert_eq!( + ok.peer_hash.map(hex::encode), + Some("00112233445566778899aabbccddeeff".to_string()) + ); + assert_eq!( + ok.remote_hash.map(hex::encode), + Some("11111111111111111111111111111111".to_string()) + ); + assert_eq!(ok.identity_path, identity); + let _ = std::fs::remove_dir_all(temp); + } +} diff --git a/crates/lxmf-tools/tests/lxmd_cli.rs b/crates/lxmf-tools/tests/lxmd_cli.rs new file mode 100644 index 0000000..aa9765f --- /dev/null +++ b/crates/lxmf-tools/tests/lxmd_cli.rs @@ -0,0 +1,329 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("lxmd-cli-{label}-{}-{nonce}", std::process::id())); + fs::create_dir_all(&path).expect("create temp test directory"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn lxmd(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_lxmd-rs")) + .args(args) + .output() + .expect("lxmd-rs subprocess should run") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn combined_output(output: &Output) -> String { + format!("{}{}", stdout(output), stderr(output)) +} + +fn write_rust_identity(path: &Path) { + rns_identity::identity::Identity::new() + .to_file(path) + .expect("write Rust identity"); +} + +#[test] +fn help_lists_cli_surface_without_starting_runtime() { + let output = lxmd(&["--help"]); + + assert!( + output.status.success(), + "expected --help to succeed, got:\n{}", + combined_output(&output) + ); + let text = stdout(&output); + assert!(text.contains("LXMF Propagation Daemon")); + assert!(text.contains("Usage: lxmd-rs [OPTIONS]")); + assert!(text.contains("--exampleconfig")); + assert!(text.contains("--status")); + assert!(text.contains("--peers")); + assert!(text.contains("--sync ")); + assert!(text.contains("-s, --service")); + assert!(text.contains("--send ")); + assert!(text.contains("[possible values: opportunistic, direct, propagated]")); +} + +#[test] +fn version_reports_binary_name_and_package_version() { + let output = lxmd(&["--version"]); + + assert!( + output.status.success(), + "expected --version to succeed, got:\n{}", + combined_output(&output) + ); + assert_eq!( + stdout(&output).trim(), + format!("lxmd-rs {}", env!("CARGO_PKG_VERSION")) + ); + assert!(stderr(&output).is_empty()); +} + +#[test] +fn rust_binary_runs_cli() { + let output = lxmd(&["--version"]); + + assert!( + output.status.success(), + "expected lxmd-rs --version to succeed, got:\n{}", + combined_output(&output) + ); + assert_eq!( + stdout(&output).trim(), + format!("lxmd-rs {}", env!("CARGO_PKG_VERSION")) + ); + assert!(stderr(&output).is_empty()); +} + +#[test] +fn example_config_exits_before_runtime_initialisation() { + let output = lxmd(&["--exampleconfig"]); + + assert!( + output.status.success(), + "expected --exampleconfig to succeed, got:\n{}", + combined_output(&output) + ); + let text = stdout(&output); + assert!(text.contains("[lxmf]")); + assert!(text.contains("display_name = Anonymous Peer")); + assert!(text.contains("announce_at_start = no")); + assert!(text.contains("delivery_transfer_max_accepted_size = 1000")); + assert!(text.contains("[propagation]")); + assert!(text.contains("enable_node = no")); + assert!(text.contains("announce_interval = 360")); + assert!(text.contains("announce_at_start = yes")); + assert!(text.contains("[logging]")); + assert!(text.contains("loglevel = 4")); + assert!(!text.contains("[control]")); + assert!( + stderr(&output).is_empty(), + "--exampleconfig should return before logging/runtime startup" + ); +} + +#[test] +fn send_method_values_parse_without_network_runtime() { + for mode in ["opportunistic", "direct", "propagated"] { + let output = lxmd(&["--exampleconfig", "--send-method", mode]); + + assert!( + output.status.success(), + "expected send method {mode:?} to parse, got:\n{}", + combined_output(&output) + ); + assert!(stdout(&output).contains("[lxmf]")); + assert!(stderr(&output).is_empty()); + } +} + +#[test] +fn clap_rejects_invalid_send_method() { + let output = lxmd(&["--send-method", "bogus"]); + + assert_eq!( + output.status.code(), + Some(2), + "expected Clap usage failure, got:\n{}", + combined_output(&output) + ); + let text = stderr(&output); + assert!(text.contains("invalid value 'bogus' for '--send-method '")); + assert!(text.contains("[possible values: opportunistic, direct, propagated]")); +} + +#[test] +fn clap_requires_send_argument() { + let output = lxmd(&["--send"]); + + assert_eq!( + output.status.code(), + Some(2), + "expected Clap usage failure, got:\n{}", + combined_output(&output) + ); + assert!(stderr(&output).contains("a value is required for '--send '")); +} + +#[test] +fn status_and_peers_query_control_and_timeout_without_daemon() { + let lxmf_dir = TestDir::new("lxmf"); + let rns_dir = TestDir::new("rns"); + write_rust_identity(&lxmf_dir.path().join("identity")); + fs::write( + rns_dir.path().join("config"), + "\ +[reticulum] +share_instance = No +enable_transport = No +respond_to_probes = No +panic_on_interface_error = No +discover_interfaces = No + +[interfaces] +", + ) + .expect("write no-interface Reticulum config"); + + let output = Command::new(env!("CARGO_BIN_EXE_lxmd-rs")) + .arg("--config") + .arg(lxmf_dir.path()) + .arg("--rnsconfig") + .arg(rns_dir.path()) + .arg("--status") + .arg("--peers") + .arg("--timeout") + .arg("0") + .output() + .expect("lxmd-rs subprocess should run"); + + assert_eq!( + output.status.code(), + Some(200), + "expected Python-compatible control timeout, got:\n{}", + combined_output(&output) + ); + assert!( + combined_output(&output).contains("Getting lxmd statistics timed out, exiting now"), + "expected Python-compatible control timeout text, got:\n{}", + combined_output(&output) + ); + assert!( + lxmf_dir.path().join("identity").is_file(), + "--config should use the configured lxmd identity path" + ); + assert!( + !lxmf_dir.path().join("storage").exists(), + "--status/--peers should not start local daemon state" + ); + + let logs = combined_output(&output); + assert!( + logs.contains("Using default configuration"), + "missing LXMF config should fall back to defaults, got logs:\n{logs}" + ); + assert!( + logs.contains("interfaces=0"), + "test config should avoid live Reticulum interfaces, got logs:\n{logs}" + ); +} + +#[test] +fn control_status_rejects_missing_identity_before_runtime() { + let lxmf_dir = TestDir::new("lxmf-missing-identity"); + let rns_dir = TestDir::new("rns-missing-identity"); + fs::write( + rns_dir.path().join("config"), + "\ +[reticulum] +share_instance = No + +[interfaces] +", + ) + .expect("write no-interface Reticulum config"); + + let output = Command::new(env!("CARGO_BIN_EXE_lxmd-rs")) + .arg("--config") + .arg(lxmf_dir.path()) + .arg("--rnsconfig") + .arg(rns_dir.path()) + .arg("--status") + .output() + .expect("lxmd-rs subprocess should run"); + + assert_eq!( + output.status.code(), + Some(202), + "expected Python-compatible missing identity exit, got:\n{}", + combined_output(&output) + ); + assert!( + combined_output(&output) + .contains("Identity file not found in specified configuration directory"), + "missing identity error should be user-visible:\n{}", + combined_output(&output) + ); + assert!( + !lxmf_dir.path().join("storage").exists(), + "control preflight should fail before daemon state is created" + ); +} + +#[test] +fn control_preflight_invalid_hashes_exit_203() { + let lxmf_dir = TestDir::new("lxmf-invalid-control"); + let rns_dir = TestDir::new("rns-invalid-control"); + write_rust_identity(&lxmf_dir.path().join("identity")); + fs::write( + rns_dir.path().join("config"), + "\ +[reticulum] +share_instance = No + +[interfaces] +", + ) + .expect("write no-interface Reticulum config"); + + for args in [ + vec!["--sync", "zz"], + vec!["--sync", "00"], + vec!["--break", "zz"], + vec!["--status", "--remote", "zz"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_lxmd-rs")) + .args(&args) + .arg("--config") + .arg(lxmf_dir.path()) + .arg("--rnsconfig") + .arg(rns_dir.path()) + .output() + .expect("lxmd-rs subprocess should run"); + + assert_eq!( + output.status.code(), + Some(203), + "expected invalid control hash exit for {args:?}, got:\n{}", + combined_output(&output) + ); + assert!( + combined_output(&output).contains("Invalid"), + "invalid hash error should be user-visible for {args:?}:\n{}", + combined_output(&output) + ); + } +}