mirror of
https://github.com/ratspeak/rsLXMF
synced 2026-08-12 18:08:14 -04:00
Initial commit: v0.9.0 release
This commit is contained in:
commit
2dae6a4d5e
33 changed files with 23578 additions and 0 deletions
64
.github/workflows/ci.yml
vendored
Normal file
64
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||
111
.github/workflows/release.yml
vendored
Normal file
111
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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/*
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target/
|
||||
*.swp
|
||||
.DS_Store
|
||||
proptest-regressions/
|
||||
2455
Cargo.lock
generated
Normal file
2455
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
61
Cargo.toml
Normal file
61
Cargo.toml
Normal file
|
|
@ -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"
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
336
README.md
Normal file
336
README.md
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<div align="center">
|
||||
|
||||
# rsLXMF
|
||||
|
||||
**Pure-Rust LXMF messaging and propagation for Reticulum.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://github.com/markqvist/LXMF)
|
||||
[](#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)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
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 <destination_hash> "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 <destination_hash> --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 <destination_hash> "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 <peer_hash>
|
||||
lxmd-rs --config ~/.rsLXMF --rnsconfig ~/.rsReticulum --break <peer_hash>
|
||||
```
|
||||
|
||||
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 <propagation_destination_hash> --timeout 5
|
||||
```
|
||||
|
||||
Control queries use `<config-dir>/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 <dir>` expects a directory and reads `<dir>/config`.
|
||||
`lxmd-rs --rnsconfig <dir>` 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-dir>/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<u8, bytes>` 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).
|
||||
29
crates/lxmf-core/Cargo.toml
Normal file
29
crates/lxmf-core/Cargo.toml
Normal file
|
|
@ -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 }
|
||||
377
crates/lxmf-core/src/constants.rs
Normal file
377
crates/lxmf-core/src/constants.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
236
crates/lxmf-core/src/discovery_stamper.rs
Normal file
236
crates/lxmf-core/src/discovery_stamper.rs
Normal file
|
|
@ -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<Vec<u8>> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
1123
crates/lxmf-core/src/handlers.rs
Normal file
1123
crates/lxmf-core/src/handlers.rs
Normal file
File diff suppressed because it is too large
Load diff
58
crates/lxmf-core/src/lib.rs
Normal file
58
crates/lxmf-core/src/lib.rs
Normal file
|
|
@ -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<u8>` is infallible, so the inner `expect` is unreachable.
|
||||
pub(crate) fn encode_value(value: &rmpv::Value) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
rmpv::encode::write_value(&mut buf, value).expect("internal: Vec<u8> write is infallible");
|
||||
buf
|
||||
}
|
||||
1448
crates/lxmf-core/src/link_delivery.rs
Normal file
1448
crates/lxmf-core/src/link_delivery.rs
Normal file
File diff suppressed because it is too large
Load diff
2285
crates/lxmf-core/src/message.rs
Normal file
2285
crates/lxmf-core/src/message.rs
Normal file
File diff suppressed because it is too large
Load diff
688
crates/lxmf-core/src/peer.rs
Normal file
688
crates/lxmf-core/src/peer.rs
Normal file
|
|
@ -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<u8>,
|
||||
f64,
|
||||
u32,
|
||||
u8,
|
||||
Option<u8>,
|
||||
Option<u8>,
|
||||
bool,
|
||||
bool,
|
||||
Vec<Vec<u8>>,
|
||||
);
|
||||
|
||||
/// 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<u8>,
|
||||
pub stamp_cost_flexibility: Option<u8>,
|
||||
/// 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<f64>,
|
||||
/// Per-sync propagation limit in KB.
|
||||
pub propagation_sync_limit: Option<f64>,
|
||||
pub currently_transferring_messages: Option<Vec<[u8; 16]>>,
|
||||
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<Vec<u8>>,
|
||||
/// 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<f64>,
|
||||
sync_limit: Option<f64>,
|
||||
stamp_cost: Option<u8>,
|
||||
stamp_flexibility: Option<u8>,
|
||||
peering_cost: Option<u8>,
|
||||
) -> 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<u8> {
|
||||
let handled: Vec<Vec<u8>> = 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<Self> {
|
||||
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<u32> {
|
||||
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<f64>) {
|
||||
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<usize> {
|
||||
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<usize> = 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);
|
||||
}
|
||||
}
|
||||
133
crates/lxmf-core/src/persist.rs
Normal file
133
crates/lxmf-core/src/persist.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//! Persistent router state — matches Python `<storagepath>/lxmf/` layout.
|
||||
//!
|
||||
//! Files:
|
||||
//! * `outbound_stamp_costs` — `HashMap<dest_hash, StampCostEntry>`
|
||||
//! * `available_tickets` — `Vec<Ticket>`
|
||||
//! * `local_deliveries` — `HashMap<transient_id, timestamp>`
|
||||
//! * `locally_processed` — `HashMap<transient_id, timestamp>`
|
||||
//!
|
||||
//! 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<T: serde::Serialize>(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<T: serde::de::DeserializeOwned>(path: &Path) -> io::Result<Option<T>> {
|
||||
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<HashMap<[u8; 16], StampCostEntry>> {
|
||||
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<Vec<Ticket>> {
|
||||
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<HashMap<[u8; 16], f64>> {
|
||||
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<HashMap<[u8; 16], f64>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
546
crates/lxmf-core/src/propagation.rs
Normal file
546
crates/lxmf-core/src/propagation.rs
Normal file
|
|
@ -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<usize>,
|
||||
}
|
||||
|
||||
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<PropagationEntry> {
|
||||
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<Item = &PropagationEntry> {
|
||||
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<u8> {
|
||||
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<Item = (&[u8; 16], &PropagationEntry)> {
|
||||
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<Vec<u8>> = (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);
|
||||
}
|
||||
}
|
||||
1288
crates/lxmf-core/src/propagation_client.rs
Normal file
1288
crates/lxmf-core/src/propagation_client.rs
Normal file
File diff suppressed because it is too large
Load diff
1787
crates/lxmf-core/src/propagation_node.rs
Normal file
1787
crates/lxmf-core/src/propagation_node.rs
Normal file
File diff suppressed because it is too large
Load diff
1065
crates/lxmf-core/src/propagation_sync.rs
Normal file
1065
crates/lxmf-core/src/propagation_sync.rs
Normal file
File diff suppressed because it is too large
Load diff
2251
crates/lxmf-core/src/router.rs
Normal file
2251
crates/lxmf-core/src/router.rs
Normal file
File diff suppressed because it is too large
Load diff
583
crates/lxmf-core/src/stamper.rs
Normal file
583
crates/lxmf-core/src/stamper.rs
Normal file
|
|
@ -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<u8>, 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<u8> {
|
||||
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<u8> {
|
||||
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<PropagationStampParts> {
|
||||
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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<DeferredStampResult>,
|
||||
) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
556
crates/lxmf-core/src/sync.rs
Normal file
556
crates/lxmf-core/src/sync.rs
Normal file
|
|
@ -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<u8>,
|
||||
pub transient_ids: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<Vec<u8>>),
|
||||
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<Vec<u8>> = 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<PeerError> {
|
||||
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<u8>) -> 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<Vec<u8>> = 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);
|
||||
}
|
||||
}
|
||||
161
crates/lxmf-core/src/ticket.rs
Normal file
161
crates/lxmf-core/src/ticket.rs
Normal file
|
|
@ -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<Ticket>,
|
||||
}
|
||||
|
||||
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<Ticket>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
28
crates/lxmf-tools/Cargo.toml
Normal file
28
crates/lxmf-tools/Cargo.toml
Normal file
|
|
@ -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"
|
||||
6
crates/lxmf-tools/src/bin/lxmd-rs.rs
Normal file
6
crates/lxmf-tools/src/bin/lxmd-rs.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[path = "../commands/lxmd.rs"]
|
||||
mod lxmd;
|
||||
|
||||
fn main() {
|
||||
lxmd::main()
|
||||
}
|
||||
2311
crates/lxmf-tools/src/commands/lxmd.rs
Normal file
2311
crates/lxmf-tools/src/commands/lxmd.rs
Normal file
File diff suppressed because it is too large
Load diff
657
crates/lxmf-tools/src/daemon.rs
Normal file
657
crates/lxmf-tools/src/daemon.rs
Normal file
|
|
@ -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<i64>,
|
||||
pub delivery_transfer_max_accepted_size: f64,
|
||||
pub on_inbound: Option<String>,
|
||||
pub enable_propagation_node: bool,
|
||||
pub node_name: Option<String>,
|
||||
pub auth_required: bool,
|
||||
pub node_announce_at_start: bool,
|
||||
pub autopeer: bool,
|
||||
pub autopeer_maxdepth: Option<i64>,
|
||||
pub node_announce_interval: Option<i64>,
|
||||
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<String>,
|
||||
pub control_allowed_identities: Vec<String>,
|
||||
pub static_peers: Vec<String>,
|
||||
pub max_peers: Option<i64>,
|
||||
pub from_static_only: bool,
|
||||
pub target_loglevel: Option<i64>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
pub node_name: Option<String>,
|
||||
pub announce_at_start: bool,
|
||||
pub announce_interval: Option<u64>,
|
||||
pub stamp_cost: Option<u8>,
|
||||
pub propagation_enabled: bool,
|
||||
pub outbound_propagation_node: Option<String>,
|
||||
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<String>,
|
||||
pub node_announce_at_start: bool,
|
||||
pub node_announce_interval: Option<u64>,
|
||||
pub auth_required: bool,
|
||||
pub control_allowed: Vec<String>,
|
||||
pub static_peers: Vec<String>,
|
||||
pub prioritise_destinations: Vec<String>,
|
||||
pub enforce_ratchets: bool,
|
||||
pub enforce_stamps: bool,
|
||||
pub message_storage_limit: Option<usize>,
|
||||
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<f64> {
|
||||
section.and_then(|sec| sec.get_float(key))
|
||||
}
|
||||
|
||||
fn seconds_to_u64(value: Option<i64>) -> Option<u64> {
|
||||
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<usize> {
|
||||
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<rns_transport::messages::TransportMessage>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
6
crates/lxmf-tools/src/lib.rs
Normal file
6
crates/lxmf-tools/src/lib.rs
Normal file
|
|
@ -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;
|
||||
336
crates/lxmf-tools/src/lxmd_cli.rs
Normal file
336
crates/lxmf-tools/src/lxmd_cli.rs
Normal file
|
|
@ -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<String>,
|
||||
|
||||
/// Path to alternative Reticulum configuration directory.
|
||||
#[arg(long)]
|
||||
pub rnsconfig: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Break peering with the specified peer and exit.
|
||||
#[arg(short = 'b', long = "break", value_name = "PEER_HASH")]
|
||||
pub unpeer: Option<String>,
|
||||
|
||||
/// Timeout in seconds for query operations.
|
||||
#[arg(long)]
|
||||
pub timeout: Option<f64>,
|
||||
|
||||
/// Remote propagation node destination hash for query operations.
|
||||
#[arg(short = 'r', long, value_name = "DEST_HASH")]
|
||||
pub remote: Option<String>,
|
||||
|
||||
/// Identity path used for remote query operations.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub identity: Option<PathBuf>,
|
||||
|
||||
/// Send a single message and exit: --send <dest_hash> <content>
|
||||
#[arg(long, num_args = 1..=2, value_names = ["DEST_HASH", "CONTENT"])]
|
||||
pub send: Option<Vec<String>>,
|
||||
|
||||
/// Read outgoing --send content from a UTF-8 file instead of argv.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub send_file: Option<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
pub fn parse_send_fields_json(raw: &str) -> Result<BTreeMap<u8, Vec<u8>>, 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::<u16>()
|
||||
.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::<Vec<_>>();
|
||||
assert_eq!(
|
||||
hashes,
|
||||
[
|
||||
"00112233445566778899aabbccddeeff",
|
||||
"aabbccddeeff00112233445566778899"
|
||||
]
|
||||
);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
1035
crates/lxmf-tools/src/lxmd_control.rs
Normal file
1035
crates/lxmf-tools/src/lxmd_control.rs
Normal file
File diff suppressed because it is too large
Load diff
564
crates/lxmf-tools/src/lxmd_runtime.rs
Normal file
564
crates/lxmf-tools/src/lxmd_runtime.rs
Normal file
|
|
@ -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<PathBuf>) -> 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<u8>) -> Vec<u8> {
|
||||
lxmf_core::handlers::get_announce_app_data(display_name, stamp_cost)
|
||||
}
|
||||
|
||||
pub fn propagation_announce_app_data(
|
||||
data: &lxmf_core::handlers::PropagationNodeAnnounceData,
|
||||
) -> Vec<u8> {
|
||||
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<ControlPreflight, ControlPreflightError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
329
crates/lxmf-tools/tests/lxmd_cli.rs
Normal file
329
crates/lxmf-tools/tests/lxmd_cli.rs
Normal file
|
|
@ -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 <PEER_HASH>"));
|
||||
assert!(text.contains("-s, --service"));
|
||||
assert!(text.contains("--send <DEST_HASH> <CONTENT>"));
|
||||
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 <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 <DEST_HASH> <CONTENT>'"));
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue