Compare commits
No commits in common. "main" and "feat/audit-fixes-bundle" have entirely different histories.
main
...
feat/audit
19 changed files with 47 additions and 1011 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
|
@ -2629,7 +2629,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onionwire"
|
name = "onionwire"
|
||||||
version = "0.2.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"arti-client",
|
"arti-client",
|
||||||
|
|
@ -2639,11 +2639,9 @@ dependencies = [
|
||||||
"md-5",
|
"md-5",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rpassword",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"safelog",
|
"safelog",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"sha3 0.10.9",
|
"sha3 0.10.9",
|
||||||
"snow",
|
"snow",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -3383,17 +3381,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rpassword"
|
|
||||||
version = "7.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rtoolbox",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
|
|
@ -3415,16 +3402,6 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rtoolbox"
|
|
||||||
version = "0.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.36.0"
|
version = "0.36.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "onionwire"
|
name = "onionwire"
|
||||||
version = "0.2.1"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
|
description = "Lean Tor messenger: Arti in-process, identity=pubkey, onion=locator. No XMPP."
|
||||||
|
|
@ -44,9 +44,7 @@ serde_json = "1"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
sha3 = "0.10"
|
sha3 = "0.10"
|
||||||
sha2 = "0.10"
|
|
||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
rpassword = "7"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -108,9 +108,9 @@ onionwire
|
||||||
|
|
||||||
or `cargo run --release`.
|
or `cargo run --release`.
|
||||||
|
|
||||||
On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
||||||
|
|
||||||
Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox.
|
Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy.
|
||||||
|
|
||||||
## Friends are keys
|
## Friends are keys
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
|
||||||
| `F3` | Paste a friend’s invite |
|
| `F3` | Paste a friend’s invite |
|
||||||
| `F4` | Rotate **onion** (locator only) |
|
| `F4` | Rotate **onion** (locator only) |
|
||||||
| `F5` | Selected friend’s profile (`/who`) |
|
| `F5` | Selected friend’s profile (`/who`) |
|
||||||
| Enter | Send chat to the selected friend, or run a `/command` |
|
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
|
||||||
| `Esc` | Close overlay / back to Main / clear composer |
|
| `Esc` | Close overlay / back to Main / clear composer |
|
||||||
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
|
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
|
||||||
|
|
||||||
|
|
@ -156,14 +156,6 @@ Give that string to a friend. They `F3` paste it (`(o) paste invite`). Unknown `
|
||||||
|
|
||||||
If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
|
If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. There is still no hosted chat server.
|
||||||
|
|
||||||
## File transfer
|
|
||||||
|
|
||||||
`/file /path` sends a local file to the selected friend. Both must be online.
|
|
||||||
Cap 1 MiB on send and receive. Fail closed: a bad chunk or hash mismatch
|
|
||||||
deletes the partial (never overwrite). Files land in
|
|
||||||
`$ONIONWIRE_HOME/inbox/<fingerprint>/`. Chat shows `[file] name (N bytes)`,
|
|
||||||
never raw frames. No outbox, no resume, no images in the TUI.
|
|
||||||
|
|
||||||
## Profile
|
## Profile
|
||||||
|
|
||||||
`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored.
|
`/profile` edits your friend-visible display name, bio, and optional Monero address (64 / 512 byte limits, no images). Enter saves and one-shot sends a signed `prf` frame to the selected friend. `F5` or `/who` shows their last signed profile. There is no directory: unknown pubkeys are ignored.
|
||||||
|
|
@ -196,7 +188,7 @@ Treat the backup file like the sqlite db.
|
||||||
|
|
||||||
## Mixed versions
|
## Mixed versions
|
||||||
|
|
||||||
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` / `fil ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
|
0.1.2 peers store unknown plaintext as chat. A 0.2 sender of `prf ` / `inv ` / `rcp ` will leave a garbage line on an un-upgraded peer. Upgrade both sides. The Noise handshake is unchanged.
|
||||||
|
|
||||||
## Wipe
|
## Wipe
|
||||||
|
|
||||||
|
|
|
||||||
38
crates/onionwire-sdk/Cargo.lock
generated
38
crates/onionwire-sdk/Cargo.lock
generated
|
|
@ -2507,16 +2507,6 @@ dependencies = [
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "md-5"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"digest 0.10.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
|
|
@ -2760,21 +2750,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onionwire"
|
name = "onionwire"
|
||||||
version = "0.2.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"arti-client",
|
"arti-client",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures",
|
"futures",
|
||||||
"md-5",
|
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rpassword",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"safelog",
|
"safelog",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha3 0.10.9",
|
|
||||||
"snow",
|
"snow",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tor-cell",
|
"tor-cell",
|
||||||
|
|
@ -2789,7 +2776,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arti-client",
|
"arti-client",
|
||||||
"onionwire",
|
"onionwire",
|
||||||
"rustls",
|
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uniffi",
|
"uniffi",
|
||||||
|
|
@ -3486,17 +3472,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rpassword"
|
|
||||||
version = "7.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rtoolbox",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
|
|
@ -3518,16 +3493,6 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rtoolbox"
|
|
||||||
version = "0.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.36.0"
|
version = "0.36.0"
|
||||||
|
|
@ -3588,7 +3553,6 @@ checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,6 @@ arti-client = { version = "0.46", default-features = false, features = [
|
||||||
"static-sqlite",
|
"static-sqlite",
|
||||||
] }
|
] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||||
# rustls 0.23 resolves its provider from its OWN `ring` / `aws-lc-rs` features,
|
|
||||||
# never from whichever crypto crates happen to be linked in the graph. Arti
|
|
||||||
# pulls rustls in through `tor-rtcompat` with `default-features = false`, so
|
|
||||||
# without this line rustls compiles with zero providers and the first TLS config
|
|
||||||
# built from the process default fails at runtime — the "Failed to start /
|
|
||||||
# Could not automatically determine the process-level CryptoProvider" screen.
|
|
||||||
#
|
|
||||||
# `ring`, not `aws-lc-rs`: aws-lc-rs needs CMake and a C toolchain for the NDK
|
|
||||||
# and fights the Android cross-compile. The `ring` crate was already in the
|
|
||||||
# graph (via `snow`, for Noise) but that is a different thing entirely.
|
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
|
||||||
uniffi = { version = "0.32", features = ["cli", "tokio"] }
|
uniffi = { version = "0.32", features = ["cli", "tokio"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,38 +17,13 @@
|
||||||
//! no TUI/ratatui type leaks into the AAR.
|
//! no TUI/ratatui type leaks into the AAR.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use onionwire::node::Node;
|
use onionwire::node::Node;
|
||||||
use onionwire::qr;
|
use onionwire::qr;
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
/// Install rustls's process-level default `CryptoProvider`, exactly once.
|
|
||||||
///
|
|
||||||
/// Belt and braces. `Cargo.toml` pins `rustls` with the `ring` feature, which
|
|
||||||
/// is what actually fixes the missing-provider failure: with it, rustls
|
|
||||||
/// resolves a provider from crate features on its own. This function exists
|
|
||||||
/// because the cdylib is loaded into a process we do not own — an explicitly
|
|
||||||
/// installed provider makes the SDK independent of how the surrounding app's
|
|
||||||
/// feature graph happens to resolve rustls.
|
|
||||||
///
|
|
||||||
/// Idempotent and cheap after the first call (`OnceLock` short-circuits it).
|
|
||||||
/// An `Err` means some provider is already installed process-wide, which is the
|
|
||||||
/// outcome we want, so it is deliberately ignored — including the case of a
|
|
||||||
/// consumer that installed `aws-lc-rs` itself. A genuine *conflict* — both
|
|
||||||
/// provider features compiled in — is a build-graph bug, not something to
|
|
||||||
/// paper over here; `Cargo.toml` enables `ring` only.
|
|
||||||
///
|
|
||||||
/// Not exported over UniFFI: it is Rust-side plumbing, not part of the Kotlin
|
|
||||||
/// surface.
|
|
||||||
pub fn install_crypto_provider() {
|
|
||||||
static INSTALLED: OnceLock<()> = OnceLock::new();
|
|
||||||
INSTALLED.get_or_init(|| {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
#[uniffi(flat_error)]
|
#[uniffi(flat_error)]
|
||||||
pub enum WireError {
|
pub enum WireError {
|
||||||
|
|
@ -113,10 +88,6 @@ pub struct Wire {
|
||||||
/// that is minutes, not seconds. Call it off the main thread.
|
/// that is minutes, not seconds. Call it off the main thread.
|
||||||
#[uniffi::export(async_runtime = "tokio")]
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
|
pub async fn open_wire(home: String, passphrase: String) -> WResult<Arc<Wire>> {
|
||||||
// Before anything that can build a TLS config: Arti's rustls backend dies
|
|
||||||
// with "Could not automatically determine the process-level CryptoProvider"
|
|
||||||
// if no provider is resolvable. See `install_crypto_provider`.
|
|
||||||
install_crypto_provider();
|
|
||||||
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
|
let node = Node::start_with_passphrase(PathBuf::from(home), &passphrase)
|
||||||
.await
|
.await
|
||||||
.map_err(WireError::new)?;
|
.map_err(WireError::new)?;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
//! `open_wire` must not depend on how the consumer's feature graph resolves a
|
|
||||||
//! rustls provider — it installs the process default itself, first.
|
|
||||||
//!
|
|
||||||
//! This is the belt-and-braces half of the fix for the on-device "Failed to
|
|
||||||
//! start / Could not automatically determine the process-level CryptoProvider"
|
|
||||||
//! screen; the primary fix is the `rustls` `ring` feature in `Cargo.toml`,
|
|
||||||
//! covered by `tests/provider_resolution.rs`. Kept in its own test binary on
|
|
||||||
//! purpose: installing a provider here would mask that test if they shared a
|
|
||||||
//! process.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sdk_installs_the_process_default_provider() {
|
|
||||||
assert!(
|
|
||||||
rustls::crypto::CryptoProvider::get_default().is_none(),
|
|
||||||
"this test must start with no provider installed"
|
|
||||||
);
|
|
||||||
|
|
||||||
onionwire_sdk::install_crypto_provider();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
rustls::crypto::CryptoProvider::get_default().is_some(),
|
|
||||||
"install_crypto_provider() left the process without a default provider"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Second call: idempotent, not a panic and not an error.
|
|
||||||
onionwire_sdk::install_crypto_provider();
|
|
||||||
|
|
||||||
// The call Arti makes that blew up on device.
|
|
||||||
let _ = rustls::ClientConfig::builder();
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
//! Regression test for the on-device failure: the APK built, installed and
|
|
||||||
//! opened, but pressing **Open** on the unlock screen died one call deep inside
|
|
||||||
//! Arti's rustls backend with
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! Could not automatically determine the process-level CryptoProvider from
|
|
||||||
//! Rustls crate features.
|
|
||||||
//! Call CryptoProvider::install_default() before this point to select a
|
|
||||||
//! provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
|
|
||||||
//! features is enabled.
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! rustls 0.23 chooses its provider from its own `ring` / `aws-lc-rs` **crate
|
|
||||||
//! features**, not from which crypto crates happen to be linked. Arti reaches
|
|
||||||
//! rustls through `tor-rtcompat` with `default-features = false`, so neither
|
|
||||||
//! provider feature is on and rustls is compiled with *no* provider at all:
|
|
||||||
//! `ring` showing up in `cargo tree` (pulled in by `snow`, for Noise) proves
|
|
||||||
//! nothing. Everything compiles, the APK ships, and the process-default lookup
|
|
||||||
//! fails at runtime.
|
|
||||||
//!
|
|
||||||
//! This test is the invariant Arti relies on: the process default must be
|
|
||||||
//! resolvable **without** anyone calling `install_default()` first.
|
|
||||||
//!
|
|
||||||
//! Deliberately the only test in this file — a second test that installs a
|
|
||||||
//! provider would race with it inside the same test binary and could mask the
|
|
||||||
//! regression.
|
|
||||||
|
|
||||||
/// Building a `rustls` config the way Arti does, straight from the process
|
|
||||||
/// default, must not panic.
|
|
||||||
#[test]
|
|
||||||
fn rustls_default_provider_resolves_without_manual_install() {
|
|
||||||
assert!(
|
|
||||||
rustls::crypto::CryptoProvider::get_default().is_none(),
|
|
||||||
"this test must start with no provider installed"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pre-fix this panics with the device's exact message.
|
|
||||||
let _ = rustls::ClientConfig::builder();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
rustls::crypto::CryptoProvider::get_default().is_some(),
|
|
||||||
"rustls resolved no CryptoProvider — the crate feature that selects a \
|
|
||||||
provider is not enabled in this workspace"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ pub enum Kind {
|
||||||
Invoice,
|
Invoice,
|
||||||
Receipt,
|
Receipt,
|
||||||
Ping,
|
Ping,
|
||||||
File,
|
|
||||||
Drop,
|
Drop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,9 +27,6 @@ pub fn classify(pt: &[u8]) -> Kind {
|
||||||
if pt.starts_with(b"png ") {
|
if pt.starts_with(b"png ") {
|
||||||
return Kind::Ping;
|
return Kind::Ping;
|
||||||
}
|
}
|
||||||
if pt.starts_with(b"fil ") {
|
|
||||||
return Kind::File;
|
|
||||||
}
|
|
||||||
if pt.len() >= 4
|
if pt.len() >= 4
|
||||||
&& pt[0].is_ascii_lowercase()
|
&& pt[0].is_ascii_lowercase()
|
||||||
&& pt[1].is_ascii_lowercase()
|
&& pt[1].is_ascii_lowercase()
|
||||||
|
|
|
||||||
387
src/file.rs
387
src/file.rs
|
|
@ -1,387 +0,0 @@
|
||||||
//! Fail-closed file frames. One file = N one-shot `fil ` payloads.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs::{self, OpenOptions};
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use rand::RngCore;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::frame;
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Error(String);
|
|
||||||
|
|
||||||
impl std::fmt::Display for Error {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
self.0.fmt(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for Error {}
|
|
||||||
|
|
||||||
impl From<std::io::Error> for Error {
|
|
||||||
fn from(e: std::io::Error) -> Self {
|
|
||||||
Self(e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const MAX_BYTES: usize = 1024 * 1024;
|
|
||||||
const PREFIX: &[u8] = b"fil ";
|
|
||||||
const NOISE_TAG: usize = 16;
|
|
||||||
const NAME_MAX: usize = 128;
|
|
||||||
const XFER_LEN: usize = 16;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Chunk {
|
|
||||||
pub xfer_id: [u8; XFER_LEN],
|
|
||||||
pub filename: String,
|
|
||||||
pub sha256: [u8; 32],
|
|
||||||
pub idx: u32,
|
|
||||||
pub total: u32,
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Inflight {
|
|
||||||
filename: String,
|
|
||||||
sha256: [u8; 32],
|
|
||||||
total: u32,
|
|
||||||
next: u32,
|
|
||||||
written: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Inbox {
|
|
||||||
root: PathBuf,
|
|
||||||
// ponytail: no timeout janitor. A vanished peer leaves .partial-* until
|
|
||||||
// a later bad chunk for that xfer_id or process exit. Size is still capped.
|
|
||||||
inflight: HashMap<[u8; XFER_LEN], Inflight>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn safe_name(name: &str) -> Result<&str> {
|
|
||||||
if name.is_empty() || name.len() > NAME_MAX {
|
|
||||||
return Err(Error("bad file name".into()));
|
|
||||||
}
|
|
||||||
if name.contains('\0')
|
|
||||||
|| name.contains('/')
|
|
||||||
|| name.contains('\n')
|
|
||||||
|| name == ".."
|
|
||||||
|| name == "."
|
|
||||||
{
|
|
||||||
return Err(Error("bad file name".into()));
|
|
||||||
}
|
|
||||||
if Path::new(name).file_name().and_then(|s| s.to_str()) != Some(name) {
|
|
||||||
return Err(Error("bad file name".into()));
|
|
||||||
}
|
|
||||||
Ok(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_fp(fp: &str) -> bool {
|
|
||||||
!fp.is_empty() && fp.len() <= 64 && fp.bytes().all(|b| b.is_ascii_hexdigit())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_limited(path: &Path) -> Result<(String, Vec<u8>)> {
|
|
||||||
let meta = fs::metadata(path)?;
|
|
||||||
if meta.len() > MAX_BYTES as u64 {
|
|
||||||
return Err(Error("file larger than 1 MiB".into()));
|
|
||||||
}
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.ok_or_else(|| Error("bad file name".into()))?
|
|
||||||
.to_string();
|
|
||||||
safe_name(&name)?;
|
|
||||||
let bytes = fs::read(path)?;
|
|
||||||
if bytes.len() > MAX_BYTES {
|
|
||||||
return Err(Error("file larger than 1 MiB".into()));
|
|
||||||
}
|
|
||||||
Ok((name, bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn chunks(filename: &str, bytes: &[u8]) -> Result<Vec<Chunk>> {
|
|
||||||
safe_name(filename)?;
|
|
||||||
if bytes.len() > MAX_BYTES {
|
|
||||||
return Err(Error("file larger than 1 MiB".into()));
|
|
||||||
}
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(bytes);
|
|
||||||
let sha256: [u8; 32] = hasher.finalize().into();
|
|
||||||
let mut xfer_id = [0u8; XFER_LEN];
|
|
||||||
rand::rngs::OsRng.fill_bytes(&mut xfer_id);
|
|
||||||
|
|
||||||
let total = total_chunks(filename, bytes.len())?;
|
|
||||||
let cap = data_cap(filename, total);
|
|
||||||
let mut out = Vec::with_capacity(total as usize);
|
|
||||||
for idx in 0..total {
|
|
||||||
let start = (idx as usize).saturating_mul(cap);
|
|
||||||
let end = (start + cap).min(bytes.len());
|
|
||||||
out.push(Chunk {
|
|
||||||
xfer_id,
|
|
||||||
filename: filename.to_string(),
|
|
||||||
sha256,
|
|
||||||
idx,
|
|
||||||
total,
|
|
||||||
data: bytes[start..end].to_vec(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode(chunk: &Chunk) -> Vec<u8> {
|
|
||||||
let mut out = Vec::from(PREFIX);
|
|
||||||
out.extend_from_slice(to_hex(&chunk.xfer_id).as_bytes());
|
|
||||||
out.push(b'\n');
|
|
||||||
out.extend_from_slice(chunk.filename.as_bytes());
|
|
||||||
out.push(b'\n');
|
|
||||||
out.extend_from_slice(to_hex(&chunk.sha256).as_bytes());
|
|
||||||
out.push(b'\n');
|
|
||||||
out.extend_from_slice(chunk.idx.to_string().as_bytes());
|
|
||||||
out.push(b'/');
|
|
||||||
out.extend_from_slice(chunk.total.to_string().as_bytes());
|
|
||||||
out.push(b'\n');
|
|
||||||
out.extend_from_slice(&chunk.data);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode(pt: &[u8]) -> Option<Chunk> {
|
|
||||||
let rest = pt.strip_prefix(PREFIX)?;
|
|
||||||
let mut parts = rest.splitn(5, |&b| b == b'\n');
|
|
||||||
let xfer_hex = std::str::from_utf8(parts.next()?).ok()?;
|
|
||||||
let filename = std::str::from_utf8(parts.next()?).ok()?;
|
|
||||||
let sha_hex = std::str::from_utf8(parts.next()?).ok()?;
|
|
||||||
let idx_total = std::str::from_utf8(parts.next()?).ok()?;
|
|
||||||
let data = parts.next()?.to_vec();
|
|
||||||
safe_name(filename).ok()?;
|
|
||||||
let xfer_id: [u8; XFER_LEN] = from_hex(xfer_hex)?.try_into().ok()?;
|
|
||||||
let sha256: [u8; 32] = from_hex(sha_hex)?.try_into().ok()?;
|
|
||||||
let (idx_s, total_s) = idx_total.split_once('/')?;
|
|
||||||
let idx: u32 = idx_s.parse().ok()?;
|
|
||||||
let total: u32 = total_s.parse().ok()?;
|
|
||||||
if total == 0 || idx >= total {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Chunk {
|
|
||||||
xfer_id,
|
|
||||||
filename: filename.to_string(),
|
|
||||||
sha256,
|
|
||||||
idx,
|
|
||||||
total,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn chat_line(name: &str, nbytes: usize) -> String {
|
|
||||||
format!("[file] {name} ({nbytes} bytes)")
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Inbox {
|
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
root: root.into(),
|
|
||||||
inflight: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ingest(&mut self, peer_fp: &str, chunk: &Chunk) -> Result<Option<PathBuf>> {
|
|
||||||
if safe_name(&chunk.filename).is_err() {
|
|
||||||
return Err(Error("bad file name".into()));
|
|
||||||
}
|
|
||||||
if chunk.total == 0 || chunk.idx >= chunk.total {
|
|
||||||
return Err(Error("bad chunk index".into()));
|
|
||||||
}
|
|
||||||
if !safe_fp(peer_fp) {
|
|
||||||
return Err(Error("bad fingerprint".into()));
|
|
||||||
}
|
|
||||||
if chunk.data.len() > MAX_BYTES {
|
|
||||||
return Err(Error("file larger than 1 MiB".into()));
|
|
||||||
}
|
|
||||||
let partial = self.partial_path(&chunk.xfer_id);
|
|
||||||
|
|
||||||
if chunk.idx == 0 {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
if let Err(e) = (|| {
|
|
||||||
ensure_dir(&self.root)?;
|
|
||||||
write_partial(&partial, &chunk.data, false)
|
|
||||||
})() {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
self.inflight.insert(
|
|
||||||
chunk.xfer_id,
|
|
||||||
Inflight {
|
|
||||||
filename: chunk.filename.clone(),
|
|
||||||
sha256: chunk.sha256,
|
|
||||||
total: chunk.total,
|
|
||||||
next: 1,
|
|
||||||
written: chunk.data.len(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let ok = self.inflight.get(&chunk.xfer_id).is_some_and(|st| {
|
|
||||||
st.filename == chunk.filename
|
|
||||||
&& st.sha256 == chunk.sha256
|
|
||||||
&& st.total == chunk.total
|
|
||||||
&& st.next == chunk.idx
|
|
||||||
});
|
|
||||||
if !ok {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
return Err(Error("chunk mismatch".into()));
|
|
||||||
}
|
|
||||||
let next_len = self
|
|
||||||
.inflight
|
|
||||||
.get(&chunk.xfer_id)
|
|
||||||
.map(|st| st.written.saturating_add(chunk.data.len()))
|
|
||||||
.unwrap_or(usize::MAX);
|
|
||||||
if next_len > MAX_BYTES {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
return Err(Error("file larger than 1 MiB".into()));
|
|
||||||
}
|
|
||||||
if let Err(e) = write_partial(&partial, &chunk.data, true) {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
if let Some(st) = self.inflight.get_mut(&chunk.xfer_id) {
|
|
||||||
st.next = chunk.idx + 1;
|
|
||||||
st.written = next_len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if chunk.idx + 1 != chunk.total {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish = (|| {
|
|
||||||
let hashed = hash_file(&partial)?;
|
|
||||||
if hashed != chunk.sha256 {
|
|
||||||
return Err(Error("hash mismatch".into()));
|
|
||||||
}
|
|
||||||
let dest_dir = self.root.join(peer_fp);
|
|
||||||
ensure_dir(&dest_dir)?;
|
|
||||||
let dest = unique_path(&dest_dir, &chunk.filename)?;
|
|
||||||
fs::rename(&partial, &dest)?;
|
|
||||||
chmod(&dest, 0o600);
|
|
||||||
Ok(dest)
|
|
||||||
})();
|
|
||||||
match finish {
|
|
||||||
Ok(dest) => {
|
|
||||||
self.inflight.remove(&chunk.xfer_id);
|
|
||||||
Ok(Some(dest))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.drop_partial(&chunk.xfer_id);
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn partial_path(&self, xfer_id: &[u8; XFER_LEN]) -> PathBuf {
|
|
||||||
self.root.join(format!(".partial-{}", to_hex(xfer_id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop_partial(&mut self, xfer_id: &[u8; XFER_LEN]) {
|
|
||||||
self.inflight.remove(xfer_id);
|
|
||||||
let _ = fs::remove_file(self.partial_path(xfer_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn total_chunks(filename: &str, len: usize) -> Result<u32> {
|
|
||||||
if len == 0 {
|
|
||||||
return Ok(1);
|
|
||||||
}
|
|
||||||
let mut total = 1u32;
|
|
||||||
loop {
|
|
||||||
let cap = data_cap(filename, total);
|
|
||||||
if cap == 0 {
|
|
||||||
return Err(Error("file name too long for a frame".into()));
|
|
||||||
}
|
|
||||||
let need = u32::try_from(len.div_ceil(cap)).map_err(|_| Error("too many chunks".into()))?;
|
|
||||||
if need <= total {
|
|
||||||
return Ok(need.max(1));
|
|
||||||
}
|
|
||||||
total = need;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn data_cap(filename: &str, total: u32) -> usize {
|
|
||||||
let digits = total.to_string().len().max(1);
|
|
||||||
let header =
|
|
||||||
PREFIX.len() + XFER_LEN * 2 + 1 + filename.len() + 1 + 64 + 1 + digits + 1 + digits + 1;
|
|
||||||
frame::MAX_FRAME
|
|
||||||
.saturating_sub(NOISE_TAG)
|
|
||||||
.saturating_sub(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_partial(path: &Path, data: &[u8], append: bool) -> Result<()> {
|
|
||||||
let mut opts = OpenOptions::new();
|
|
||||||
opts.write(true).mode(0o600);
|
|
||||||
if append {
|
|
||||||
opts.append(true);
|
|
||||||
} else {
|
|
||||||
opts.create(true).truncate(true);
|
|
||||||
}
|
|
||||||
let mut f = opts.open(path)?;
|
|
||||||
f.write_all(data)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_file(path: &Path) -> Result<[u8; 32]> {
|
|
||||||
let mut f = fs::File::open(path)?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
let mut buf = [0u8; 8192];
|
|
||||||
loop {
|
|
||||||
let n = f.read(&mut buf)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buf[..n]);
|
|
||||||
}
|
|
||||||
Ok(hasher.finalize().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_path(dir: &Path, name: &str) -> Result<PathBuf> {
|
|
||||||
let first = dir.join(name);
|
|
||||||
if !first.exists() {
|
|
||||||
return Ok(first);
|
|
||||||
}
|
|
||||||
for n in 2..1000 {
|
|
||||||
let p = dir.join(format!("{name}-{n}"));
|
|
||||||
if !p.exists() {
|
|
||||||
return Ok(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(Error("name collision".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_dir(path: &Path) -> Result<()> {
|
|
||||||
fs::create_dir_all(path)?;
|
|
||||||
chmod(path, 0o700);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn chmod(path: &Path, mode: u32) {
|
|
||||||
if let Ok(meta) = fs::metadata(path) {
|
|
||||||
let mut p = meta.permissions();
|
|
||||||
p.set_mode(mode);
|
|
||||||
let _ = fs::set_permissions(path, p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_hex(bytes: &[u8]) -> String {
|
|
||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_hex(s: &str) -> Option<Vec<u8>> {
|
|
||||||
if s.is_empty() || !s.len().is_multiple_of(2) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
(0..s.len())
|
|
||||||
.step_by(2)
|
|
||||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod file;
|
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod hs;
|
pub mod hs;
|
||||||
pub mod loc;
|
pub mod loc;
|
||||||
|
|
@ -14,7 +13,4 @@ mod store;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
pub mod wallet;
|
pub mod wallet;
|
||||||
|
|
||||||
pub use store::{
|
pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store};
|
||||||
Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store,
|
|
||||||
resolve_store_passphrase,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
23
src/main.rs
23
src/main.rs
|
|
@ -1,4 +1,5 @@
|
||||||
use onionwire::tui::AppExit;
|
use onionwire::tui::AppExit;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
let v = env!("CARGO_PKG_VERSION");
|
let v = env!("CARGO_PKG_VERSION");
|
||||||
|
|
@ -34,8 +35,7 @@ async fn main() {
|
||||||
|
|
||||||
async fn boot() -> Result<(), String> {
|
async fn boot() -> Result<(), String> {
|
||||||
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
|
let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?;
|
||||||
let pass = onionwire::resolve_store_passphrase(|p| rpassword::prompt_password(p))
|
let pass = store_passphrase()?;
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
eprintln!("onionwire: bootstrapping Arti…");
|
eprintln!("onionwire: bootstrapping Arti…");
|
||||||
let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?;
|
let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?;
|
||||||
let handle = tokio::runtime::Handle::current();
|
let handle = tokio::runtime::Handle::current();
|
||||||
|
|
@ -48,3 +48,22 @@ async fn boot() -> Result<(), String> {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn store_passphrase() -> Result<String, String> {
|
||||||
|
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
|
||||||
|
Ok(p) if p.is_empty() => Err("empty passphrase".into()),
|
||||||
|
Ok(p) => Ok(p),
|
||||||
|
Err(_) => {
|
||||||
|
eprint!("onionwire: store passphrase: ");
|
||||||
|
let _ = io::stderr().flush();
|
||||||
|
let mut s = String::new();
|
||||||
|
io::stdin().read_line(&mut s).map_err(|e| e.to_string())?;
|
||||||
|
let s = s.trim_end_matches(['\n', '\r']).to_string();
|
||||||
|
if s.is_empty() {
|
||||||
|
Err("empty passphrase".into())
|
||||||
|
} else {
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
54
src/node.rs
54
src/node.rs
|
|
@ -10,7 +10,6 @@ use tor_cell::relaycell::msg::Connected;
|
||||||
use tor_hsservice::{RunningOnionService, handle_rend_requests};
|
use tor_hsservice::{RunningOnionService, handle_rend_requests};
|
||||||
|
|
||||||
use crate::dispatch::{self, Kind};
|
use crate::dispatch::{self, Kind};
|
||||||
use crate::file;
|
|
||||||
use crate::frame;
|
use crate::frame;
|
||||||
use crate::hs::{self, Client, HS_PORT};
|
use crate::hs::{self, Client, HS_PORT};
|
||||||
use crate::loc;
|
use crate::loc;
|
||||||
|
|
@ -47,7 +46,6 @@ pub struct Node {
|
||||||
keys: Mutex<Keys>,
|
keys: Mutex<Keys>,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
incoming_limit: Mutex<TokenBucket>,
|
incoming_limit: Mutex<TokenBucket>,
|
||||||
inbox: Mutex<file::Inbox>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Node {
|
impl Node {
|
||||||
|
|
@ -77,7 +75,6 @@ impl Node {
|
||||||
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
|
.ok_or_else(|| "onion service disabled in config — fail closed".to_string())?;
|
||||||
let (svc, rend) = launched;
|
let (svc, rend) = launched;
|
||||||
let onion = hs::onion_string(&svc)?;
|
let onion = hs::onion_string(&svc)?;
|
||||||
let inbox = file::Inbox::new(home.join("inbox"));
|
|
||||||
let node = Arc::new(Self {
|
let node = Arc::new(Self {
|
||||||
home,
|
home,
|
||||||
store: Mutex::new(store),
|
store: Mutex::new(store),
|
||||||
|
|
@ -87,7 +84,6 @@ impl Node {
|
||||||
keys: Mutex::new(keys),
|
keys: Mutex::new(keys),
|
||||||
wallet: Wallet::from_env(),
|
wallet: Wallet::from_env(),
|
||||||
incoming_limit: Mutex::new(TokenBucket::default()),
|
incoming_limit: Mutex::new(TokenBucket::default()),
|
||||||
inbox: Mutex::new(inbox),
|
|
||||||
});
|
});
|
||||||
// Accept rens before waiting so a reachability probe can succeed
|
// Accept rens before waiting so a reachability probe can succeed
|
||||||
// while combined status is still Bootstrapping.
|
// while combined status is still Bootstrapping.
|
||||||
|
|
@ -485,25 +481,6 @@ impl Node {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot file to a friend. Fail closed; no outbox, no resume.
|
|
||||||
pub async fn send_file(&self, friend_pk: &[u8], path: &Path) -> Result<String, String> {
|
|
||||||
let (name, bytes) = file::read_limited(path).map_err(|e| e.to_string())?;
|
|
||||||
let chunks = file::chunks(&name, &bytes).map_err(|e| e.to_string())?;
|
|
||||||
for chunk in &chunks {
|
|
||||||
self.send_once(friend_pk, &file::encode(chunk)).await?;
|
|
||||||
}
|
|
||||||
self.store
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.append_message(
|
|
||||||
friend_pk,
|
|
||||||
"out",
|
|
||||||
file::chat_line(&name, bytes.len()).as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
|
pub async fn send(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> {
|
||||||
let (onion, prekey) = {
|
let (onion, prekey) = {
|
||||||
let store = self.store.lock().map_err(|e| e.to_string())?;
|
let store = self.store.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
@ -643,41 +620,10 @@ impl Node {
|
||||||
}
|
}
|
||||||
Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await,
|
Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await,
|
||||||
Kind::Receipt => self.ingest_receipt(&sess.peer_identity, &pt).await,
|
Kind::Receipt => self.ingest_receipt(&sess.peer_identity, &pt).await,
|
||||||
Kind::File => self.ingest_file(&sess.peer_identity, &pt),
|
|
||||||
Kind::Ping | Kind::Drop => Ok(()),
|
Kind::Ping | Kind::Drop => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ingest_file(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
|
|
||||||
let Some(chunk) = file::decode(pt) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let fp: String = peer.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
let done = {
|
|
||||||
let mut inbox = self.inbox.lock().map_err(|e| e.to_string())?;
|
|
||||||
match inbox.ingest(&fp, &chunk) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("fil dropped ({e})");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(path) = done {
|
|
||||||
let nbytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0) as usize;
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or(&chunk.filename);
|
|
||||||
self.store
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.append_message(peer, "in", file::chat_line(name, nbytes).as_bytes())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
|
async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> {
|
||||||
let Some(inv) = pay::decode_invoice(pt) else {
|
let Some(inv) = pay::decode_invoice(pt) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
24
src/store.rs
24
src/store.rs
|
|
@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rand::RngCore;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
@ -831,26 +831,6 @@ fn passphrase_from_env() -> Result<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Env `ONIONWIRE_STORE_PASSPHRASE` if set (non-empty). Otherwise `read_secret`
|
|
||||||
/// (TTY, no echo). Empty values fail closed.
|
|
||||||
pub fn resolve_store_passphrase(
|
|
||||||
read_secret: impl FnOnce(&str) -> std::io::Result<String>,
|
|
||||||
) -> Result<String> {
|
|
||||||
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
|
|
||||||
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
|
|
||||||
Ok(p) => Ok(p),
|
|
||||||
Err(_) => {
|
|
||||||
let s = read_secret("onionwire: store passphrase: ")?;
|
|
||||||
let s = s.trim_end_matches(['\n', '\r']).to_string();
|
|
||||||
if s.is_empty() {
|
|
||||||
Err(Error("empty passphrase".into()))
|
|
||||||
} else {
|
|
||||||
Ok(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mkdir_700(path: &Path) -> Result<()> {
|
fn mkdir_700(path: &Path) -> Result<()> {
|
||||||
fs::create_dir_all(path)?;
|
fs::create_dir_all(path)?;
|
||||||
let mut perms = fs::metadata(path)?.permissions();
|
let mut perms = fs::metadata(path)?.permissions();
|
||||||
|
|
|
||||||
99
src/tui.rs
99
src/tui.rs
|
|
@ -68,7 +68,7 @@ OnionWire keys\n\
|
||||||
1 / 2 / 3 focus roster / chat / composer\n\
|
1 / 2 / 3 focus roster / chat / composer\n\
|
||||||
j k or Up Down move or scroll focused pane\n\
|
j k or Up Down move or scroll focused pane\n\
|
||||||
g / G jump to top / bottom\n\
|
g / G jump to top / bottom\n\
|
||||||
Enter send chat to selected friend, or a /command\n\
|
Enter /wipe /wipe-all /profile /who /pay /tip\n\
|
||||||
Esc close overlay, go back, clear composer\n\
|
Esc close overlay, go back, clear composer\n\
|
||||||
F2 share invite\n\
|
F2 share invite\n\
|
||||||
F3 paste invite\n\
|
F3 paste invite\n\
|
||||||
|
|
@ -77,7 +77,6 @@ OnionWire keys\n\
|
||||||
/profile edit name, bio, Monero address\n\
|
/profile edit name, bio, Monero address\n\
|
||||||
/pay <xmr> [memo] invoice to receive\n\
|
/pay <xmr> [memo] invoice to receive\n\
|
||||||
/tip <xmr> [memo] pay selected friend\n\
|
/tip <xmr> [memo] pay selected friend\n\
|
||||||
/file /path send file to selected friend (1 MiB)\n\
|
|
||||||
/backup /path encrypted identity export\n\
|
/backup /path encrypted identity export\n\
|
||||||
/restore /path overwrite self keys\n\
|
/restore /path overwrite self keys\n\
|
||||||
? this help\n\
|
? this help\n\
|
||||||
|
|
@ -164,29 +163,6 @@ pub enum SlashCmd {
|
||||||
Tip { atomic: String, memo: String },
|
Tip { atomic: String, memo: String },
|
||||||
Backup { path: String },
|
Backup { path: String },
|
||||||
Restore { path: String },
|
Restore { path: String },
|
||||||
File { path: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ComposerAction {
|
|
||||||
Cmd(SlashCmd),
|
|
||||||
Send(String),
|
|
||||||
UnknownSlash(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter in the composer: slash command, chat send, or unknown `/cmd`.
|
|
||||||
pub fn composer_enter(raw: &str) -> Option<ComposerAction> {
|
|
||||||
let s = raw.trim();
|
|
||||||
if s.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Some(cmd) = parse_cmd(raw) {
|
|
||||||
return Some(ComposerAction::Cmd(cmd));
|
|
||||||
}
|
|
||||||
if s.starts_with('/') {
|
|
||||||
return Some(ComposerAction::UnknownSlash(s.to_string()));
|
|
||||||
}
|
|
||||||
Some(ComposerAction::Send(s.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -308,11 +284,6 @@ pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
|
||||||
{
|
{
|
||||||
return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path });
|
return parse_path_cmd(rest).map(|path| SlashCmd::Restore { path });
|
||||||
}
|
}
|
||||||
if let Some(rest) = s.strip_prefix("/file")
|
|
||||||
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
|
|
||||||
{
|
|
||||||
return parse_path_cmd(rest).map(|path| SlashCmd::File { path });
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -722,8 +693,6 @@ impl App {
|
||||||
let mut open_profile = false;
|
let mut open_profile = false;
|
||||||
let mut pay_cmd = None;
|
let mut pay_cmd = None;
|
||||||
let mut tip_cmd = None;
|
let mut tip_cmd = None;
|
||||||
let mut file_cmd = None;
|
|
||||||
let mut chat_send = None;
|
|
||||||
match &mut self.screen {
|
match &mut self.screen {
|
||||||
Screen::Main => match key.code {
|
Screen::Main => match key.code {
|
||||||
KeyCode::Tab => self.focus = self.focus.next(),
|
KeyCode::Tab => self.focus = self.focus.next(),
|
||||||
|
|
@ -749,8 +718,8 @@ impl App {
|
||||||
self.composer.pop();
|
self.composer.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => match composer_enter(&self.composer) {
|
KeyCode::Enter => match parse_cmd(&self.composer) {
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => {
|
Some(SlashCmd::Wipe(kind)) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
let prompt = match kind {
|
let prompt = match kind {
|
||||||
WipeKind::Messages => WipePrompt::messages(),
|
WipeKind::Messages => WipePrompt::messages(),
|
||||||
|
|
@ -758,23 +727,23 @@ impl App {
|
||||||
};
|
};
|
||||||
self.screen = Screen::Wipe { kind, prompt };
|
self.screen = Screen::Wipe { kind, prompt };
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Profile)) => {
|
Some(SlashCmd::Profile) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
open_profile = true;
|
open_profile = true;
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Who)) => {
|
Some(SlashCmd::Who) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.who_open = true;
|
self.who_open = true;
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => {
|
Some(SlashCmd::Pay { atomic, memo }) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
pay_cmd = Some((atomic, memo));
|
pay_cmd = Some((atomic, memo));
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => {
|
Some(SlashCmd::Tip { atomic, memo }) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
tip_cmd = Some((atomic, memo));
|
tip_cmd = Some((atomic, memo));
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => {
|
Some(SlashCmd::Backup { path }) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.screen = Screen::ConfirmKeys {
|
self.screen = Screen::ConfirmKeys {
|
||||||
kind: BackupKind::Backup,
|
kind: BackupKind::Backup,
|
||||||
|
|
@ -782,7 +751,7 @@ impl App {
|
||||||
prompt: BackupPrompt::backup(),
|
prompt: BackupPrompt::backup(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => {
|
Some(SlashCmd::Restore { path }) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.screen = Screen::ConfirmKeys {
|
self.screen = Screen::ConfirmKeys {
|
||||||
kind: BackupKind::Restore,
|
kind: BackupKind::Restore,
|
||||||
|
|
@ -790,18 +759,6 @@ impl App {
|
||||||
prompt: BackupPrompt::restore(),
|
prompt: BackupPrompt::restore(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Some(ComposerAction::Cmd(SlashCmd::File { path })) => {
|
|
||||||
self.composer.clear();
|
|
||||||
file_cmd = Some(path);
|
|
||||||
}
|
|
||||||
Some(ComposerAction::Send(text)) => {
|
|
||||||
self.composer.clear();
|
|
||||||
chat_send = Some(text);
|
|
||||||
}
|
|
||||||
Some(ComposerAction::UnknownSlash(cmd)) => {
|
|
||||||
self.composer.clear();
|
|
||||||
self.status_note = Some(format!("unknown command {cmd}"));
|
|
||||||
}
|
|
||||||
None => {}
|
None => {}
|
||||||
},
|
},
|
||||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
|
@ -955,12 +912,6 @@ impl App {
|
||||||
if let Some((atomic, memo)) = tip_cmd {
|
if let Some((atomic, memo)) = tip_cmd {
|
||||||
self.send_tip(&atomic, &memo)?;
|
self.send_tip(&atomic, &memo)?;
|
||||||
}
|
}
|
||||||
if let Some(path) = file_cmd {
|
|
||||||
self.send_file(&path)?;
|
|
||||||
}
|
|
||||||
if let Some(text) = chat_send {
|
|
||||||
self.send_chat(&text)?;
|
|
||||||
}
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1212,36 +1163,6 @@ impl App {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_file(&mut self, path: &str) -> Result<(), String> {
|
|
||||||
let Some(friend) = self.friends.get(self.selected) else {
|
|
||||||
self.status_note = Some("no friend selected".into());
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let pk = friend.pubkey.clone();
|
|
||||||
self.status_note = Some("sending".into());
|
|
||||||
match self
|
|
||||||
.rt
|
|
||||||
.block_on(self.node.send_file(&pk, std::path::Path::new(path)))
|
|
||||||
{
|
|
||||||
Ok(name) => self.status_note = Some(format!("sent file {name}")),
|
|
||||||
Err(e) => self.status_note = Some(format!("send failed: {e}")),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_chat(&mut self, text: &str) -> Result<(), String> {
|
|
||||||
let Some(friend) = self.friends.get(self.selected) else {
|
|
||||||
self.status_note = Some("no friend selected".into());
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let pk = friend.pubkey.clone();
|
|
||||||
match self.rt.block_on(self.node.send(&pk, text.as_bytes())) {
|
|
||||||
Ok(()) => self.status_note = Some("sent".into()),
|
|
||||||
Err(e) => self.status_note = Some(format!("send failed: {e}")),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn who_body(&self) -> String {
|
fn who_body(&self) -> String {
|
||||||
let Some(friend) = self.friends.get(self.selected) else {
|
let Some(friend) = self.friends.get(self.selected) else {
|
||||||
return "no friend selected\n\nEsc closes".into();
|
return "no friend selected\n\nEsc closes".into();
|
||||||
|
|
@ -1373,7 +1294,7 @@ impl App {
|
||||||
f.render_widget(chat, panes[1]);
|
f.render_widget(chat, panes[1]);
|
||||||
|
|
||||||
let cmd = if self.composer.is_empty() {
|
let cmd = if self.composer.is_empty() {
|
||||||
"/wipe /wipe-all /profile /who /pay /tip /file".to_string()
|
"/wipe /wipe-all /profile /who /pay /tip".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.composer.clone()
|
self.composer.clone()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
133
tests/file.rs
133
tests/file.rs
|
|
@ -1,133 +0,0 @@
|
||||||
//! M10: fail-closed file transfer — frames, names, assemble, slash parse.
|
|
||||||
|
|
||||||
use onionwire::dispatch::{Kind, classify};
|
|
||||||
use onionwire::file::{self, MAX_BYTES};
|
|
||||||
use onionwire::tui::{SlashCmd, parse_cmd};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode_decode_roundtrip_one_chunk() {
|
|
||||||
let body = b"hello file";
|
|
||||||
let chunks = file::chunks("note.txt", body).unwrap();
|
|
||||||
assert_eq!(chunks.len(), 1);
|
|
||||||
let encoded = file::encode(&chunks[0]);
|
|
||||||
let decoded = file::decode(&encoded).expect("decode");
|
|
||||||
assert_eq!(decoded.filename, "note.txt");
|
|
||||||
assert_eq!(decoded.idx, 0);
|
|
||||||
assert_eq!(decoded.total, 1);
|
|
||||||
assert_eq!(decoded.data, body);
|
|
||||||
assert_eq!(decoded.sha256, chunks[0].sha256);
|
|
||||||
assert_eq!(decoded.xfer_id, chunks[0].xfer_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chat_is_not_file() {
|
|
||||||
assert_eq!(file::decode(b"hello wire"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn name_with_dotdot_or_slash_rejected() {
|
|
||||||
assert!(file::chunks("../secret", b"x").is_err());
|
|
||||||
assert!(file::chunks("a/b", b"x").is_err());
|
|
||||||
assert!(file::safe_name("..").is_err());
|
|
||||||
assert!(file::safe_name("foo/bar").is_err());
|
|
||||||
assert!(file::safe_name("a\0b").is_err());
|
|
||||||
assert!(file::safe_name("ok.txt").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oversize_rejected_before_send() {
|
|
||||||
let too_big = vec![0u8; MAX_BYTES + 1];
|
|
||||||
assert!(file::chunks("big.bin", &too_big).is_err());
|
|
||||||
assert!(file::chunks("ok.bin", &vec![0u8; MAX_BYTES]).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assemble_two_chunks_writes_file_and_matches_hash() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let body = vec![7u8; 80_000];
|
|
||||||
let chunks = file::chunks("pic.bin", &body).unwrap();
|
|
||||||
assert!(chunks.len() >= 2, "expected split, got {}", chunks.len());
|
|
||||||
let mut inbox = file::Inbox::new(dir.path());
|
|
||||||
let fp = "aabbccddeeff";
|
|
||||||
let mut done = None;
|
|
||||||
for c in &chunks {
|
|
||||||
done = inbox.ingest(fp, c).unwrap();
|
|
||||||
}
|
|
||||||
let path = done.expect("assembled path");
|
|
||||||
assert_eq!(std::fs::read(&path).unwrap(), body);
|
|
||||||
assert!(path.ends_with("pic.bin"));
|
|
||||||
assert!(path.to_string_lossy().contains(fp));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bad_hash_leaves_no_inbox_file() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let chunks = file::chunks("evil.bin", b"payload").unwrap();
|
|
||||||
let mut bad = chunks[0].clone();
|
|
||||||
bad.sha256 = [0u8; 32];
|
|
||||||
let mut inbox = file::Inbox::new(dir.path());
|
|
||||||
let fp = "deadbeef";
|
|
||||||
assert!(inbox.ingest(fp, &bad).is_err());
|
|
||||||
let dest = dir.path().join(fp).join("evil.bin");
|
|
||||||
assert!(!dest.exists(), "hash mismatch must not write inbox file");
|
|
||||||
let partials: Vec<_> = std::fs::read_dir(dir.path())
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_name().to_string_lossy().starts_with(".partial-"))
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
partials.is_empty(),
|
|
||||||
"partial must be deleted on hash mismatch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ingest_rejects_oversize_and_deletes_partial() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let mut inbox = file::Inbox::new(dir.path());
|
|
||||||
let mut chunk = file::chunks("fat.bin", b"x").unwrap().remove(0);
|
|
||||||
chunk.total = 2;
|
|
||||||
chunk.idx = 0;
|
|
||||||
chunk.data = vec![1u8; MAX_BYTES + 1];
|
|
||||||
assert!(inbox.ingest("aa", &chunk).is_err());
|
|
||||||
assert!(!dir.path().join("aa").join("fat.bin").exists());
|
|
||||||
let leftover: Vec<_> = std::fs::read_dir(dir.path())
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.filter(|e| e.file_name().to_string_lossy().starts_with(".partial-"))
|
|
||||||
.collect();
|
|
||||||
assert!(leftover.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ingest_rejects_cumulative_oversize() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let mut inbox = file::Inbox::new(dir.path());
|
|
||||||
let mut a = file::chunks("fat.bin", b"x").unwrap().remove(0);
|
|
||||||
a.total = 2;
|
|
||||||
a.idx = 0;
|
|
||||||
a.data = vec![1u8; MAX_BYTES - 10];
|
|
||||||
assert_eq!(inbox.ingest("aa", &a).unwrap(), None);
|
|
||||||
let mut b = a.clone();
|
|
||||||
b.idx = 1;
|
|
||||||
b.data = vec![1u8; 11];
|
|
||||||
assert!(inbox.ingest("aa", &b).is_err());
|
|
||||||
assert!(!dir.path().join("aa").join("fat.bin").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slash_file_parses_path_and_empty_is_none() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_cmd("/file /tmp/a"),
|
|
||||||
Some(SlashCmd::File {
|
|
||||||
path: "/tmp/a".into()
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(parse_cmd("/file"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dispatch_fil_is_file_not_chat() {
|
|
||||||
assert_eq!(classify(b"fil abc"), Kind::File);
|
|
||||||
assert_eq!(classify(b"hello wire"), Kind::Chat);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
//! Composer Enter must send chat to the selected friend, not only slash commands.
|
|
||||||
|
|
||||||
use onionwire::tui::{composer_enter, ComposerAction, SlashCmd, WipeKind};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_plain_text_is_send() {
|
|
||||||
assert_eq!(
|
|
||||||
composer_enter("hello wire"),
|
|
||||||
Some(ComposerAction::Send("hello wire".into()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_trims_but_sends() {
|
|
||||||
assert_eq!(
|
|
||||||
composer_enter(" hi there "),
|
|
||||||
Some(ComposerAction::Send("hi there".into()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_empty_does_nothing() {
|
|
||||||
assert_eq!(composer_enter(""), None);
|
|
||||||
assert_eq!(composer_enter(" "), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_slash_cmds_are_not_chat() {
|
|
||||||
assert_eq!(
|
|
||||||
composer_enter("/wipe"),
|
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Wipe(WipeKind::Messages)))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
composer_enter("/who"),
|
|
||||||
Some(ComposerAction::Cmd(SlashCmd::Who))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_unknown_slash_is_not_chat() {
|
|
||||||
assert_eq!(
|
|
||||||
composer_enter("/nope"),
|
|
||||||
Some(ComposerAction::UnknownSlash("/nope".into()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -264,64 +264,3 @@ fn reopen_with_same_passphrase_decrypts() {
|
||||||
let msgs = store.list_messages(&pk(1)).unwrap();
|
let msgs = store.list_messages(&pk(1)).unwrap();
|
||||||
assert_eq!(msgs[0].plaintext, b"hello again");
|
assert_eq!(msgs[0].plaintext, b"hello again");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_passphrase_skips_prompt() {
|
|
||||||
let _g = ENV_LOCK.lock().expect("env lock");
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env");
|
|
||||||
}
|
|
||||||
let mut prompted = false;
|
|
||||||
let got = onionwire::resolve_store_passphrase(|_| {
|
|
||||||
prompted = true;
|
|
||||||
Ok("from-tty".into())
|
|
||||||
});
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
||||||
}
|
|
||||||
assert_eq!(got.unwrap(), "from-env");
|
|
||||||
assert!(!prompted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_env_passphrase_is_rejected_without_prompt() {
|
|
||||||
let _g = ENV_LOCK.lock().expect("env lock");
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "");
|
|
||||||
}
|
|
||||||
let mut prompted = false;
|
|
||||||
let err = onionwire::resolve_store_passphrase(|_| {
|
|
||||||
prompted = true;
|
|
||||||
Ok("from-tty".into())
|
|
||||||
})
|
|
||||||
.unwrap_err();
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
||||||
}
|
|
||||||
assert!(err.to_string().contains("empty"), "got {err}");
|
|
||||||
assert!(!prompted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_env_reads_secret_and_strips_newline() {
|
|
||||||
let _g = ENV_LOCK.lock().expect("env lock");
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
||||||
}
|
|
||||||
let got = onionwire::resolve_store_passphrase(|prompt| {
|
|
||||||
assert!(prompt.contains("store passphrase"), "prompt {prompt}");
|
|
||||||
Ok("secret-from-tty\n".into())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(got, "secret-from-tty");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_env_empty_secret_is_rejected() {
|
|
||||||
let _g = ENV_LOCK.lock().expect("env lock");
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
||||||
}
|
|
||||||
let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("empty"), "got {err}");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
use onionwire::qr;
|
use onionwire::qr;
|
||||||
use onionwire::tui::{
|
use onionwire::tui::{
|
||||||
Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
|
banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints,
|
||||||
onion_glyph, status_footer, wordmark_banner,
|
onion_glyph, status_footer, wordmark_banner, Pane,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -40,19 +40,7 @@ fn help_overlay_lists_core_bindings() {
|
||||||
let help = help_overlay_text();
|
let help = help_overlay_text();
|
||||||
assert!(!help.is_empty());
|
assert!(!help.is_empty());
|
||||||
for needle in [
|
for needle in [
|
||||||
"Tab",
|
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q",
|
||||||
"F2",
|
|
||||||
"F3",
|
|
||||||
"F4",
|
|
||||||
"F5",
|
|
||||||
"send chat",
|
|
||||||
"/profile",
|
|
||||||
"/pay",
|
|
||||||
"/tip",
|
|
||||||
"/file",
|
|
||||||
"/backup",
|
|
||||||
"/restore",
|
|
||||||
"Ctrl-Q",
|
|
||||||
"?",
|
"?",
|
||||||
] {
|
] {
|
||||||
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
||||||
|
|
@ -77,10 +65,10 @@ fn banner_for_width_collapses_when_narrow() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chrome_renders_at_80x24_and_120x40() {
|
fn chrome_renders_at_80x24_and_120x40() {
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
let fp = "abcdef0123456789";
|
let fp = "abcdef0123456789";
|
||||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
||||||
|
|
@ -178,8 +166,8 @@ fn restore_terminal_is_callable_without_panic() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn share_screen_shows_invite_not_qr_at_80x24() {
|
fn share_screen_shows_invite_not_qr_at_80x24() {
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
let sk = [7u8; 32];
|
let sk = [7u8; 32];
|
||||||
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue