From c4ccca7e8c7812f6642efbad7d26387de7829e72 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 23:07:22 -0400 Subject: [PATCH 1/2] feat(tui): Enter sends chat to the selected friend Composer Enter only ran slash commands, so typed messages were dropped. Wire Node::send, keep /commands, reject unknown /foo as not-chat. --- README.md | 4 +-- src/tui.rs | 65 ++++++++++++++++++++++++++++++++++++++------- tests/send.rs | 45 +++++++++++++++++++++++++++++++ tests/tui_chrome.rs | 4 +-- 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/send.rs diff --git a/README.md b/README.md index 29785e0..6a0482f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ 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. -Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. +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. ## 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 | | `F4` | Rotate **onion** (locator only) | | `F5` | Selected friend’s profile (`/who`) | -| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer | +| Enter | Send chat to the selected friend, or run a `/command` | | `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 | diff --git a/src/tui.rs b/src/tui.rs index 7d5c00f..88bd2c5 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -68,7 +68,7 @@ OnionWire keys\n\ 1 / 2 / 3 focus roster / chat / composer\n\ j k or Up Down move or scroll focused pane\n\ g / G jump to top / bottom\n\ - Enter /wipe /wipe-all /profile /who /pay /tip\n\ + Enter send chat to selected friend, or a /command\n\ Esc close overlay, go back, clear composer\n\ F2 share invite\n\ F3 paste invite\n\ @@ -165,6 +165,28 @@ pub enum SlashCmd { Restore { 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 { + 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)] pub struct ProfileDraft { pub display_name: String, @@ -693,6 +715,7 @@ impl App { let mut open_profile = false; let mut pay_cmd = None; let mut tip_cmd = None; + let mut chat_send = None; match &mut self.screen { Screen::Main => match key.code { KeyCode::Tab => self.focus = self.focus.next(), @@ -718,8 +741,8 @@ impl App { self.composer.pop(); } } - KeyCode::Enter => match parse_cmd(&self.composer) { - Some(SlashCmd::Wipe(kind)) => { + KeyCode::Enter => match composer_enter(&self.composer) { + Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => { self.composer.clear(); let prompt = match kind { WipeKind::Messages => WipePrompt::messages(), @@ -727,23 +750,23 @@ impl App { }; self.screen = Screen::Wipe { kind, prompt }; } - Some(SlashCmd::Profile) => { + Some(ComposerAction::Cmd(SlashCmd::Profile)) => { self.composer.clear(); open_profile = true; } - Some(SlashCmd::Who) => { + Some(ComposerAction::Cmd(SlashCmd::Who)) => { self.composer.clear(); self.who_open = true; } - Some(SlashCmd::Pay { atomic, memo }) => { + Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => { self.composer.clear(); pay_cmd = Some((atomic, memo)); } - Some(SlashCmd::Tip { atomic, memo }) => { + Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => { self.composer.clear(); tip_cmd = Some((atomic, memo)); } - Some(SlashCmd::Backup { path }) => { + Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => { self.composer.clear(); self.screen = Screen::ConfirmKeys { kind: BackupKind::Backup, @@ -751,7 +774,7 @@ impl App { prompt: BackupPrompt::backup(), }; } - Some(SlashCmd::Restore { path }) => { + Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => { self.composer.clear(); self.screen = Screen::ConfirmKeys { kind: BackupKind::Restore, @@ -759,6 +782,14 @@ impl App { prompt: BackupPrompt::restore(), }; } + 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 => {} }, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -912,6 +943,9 @@ impl App { if let Some((atomic, memo)) = tip_cmd { self.send_tip(&atomic, &memo)?; } + if let Some(text) = chat_send { + self.send_chat(&text)?; + } Ok(None) } @@ -1163,6 +1197,19 @@ impl App { 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 { let Some(friend) = self.friends.get(self.selected) else { return "no friend selected\n\nEsc closes".into(); diff --git a/tests/send.rs b/tests/send.rs new file mode 100644 index 0000000..8aedab5 --- /dev/null +++ b/tests/send.rs @@ -0,0 +1,45 @@ +//! 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())) + ); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 35be74b..5edd606 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -40,8 +40,8 @@ fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); for needle in [ - "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q", - "?", + "Tab", "F2", "F3", "F4", "F5", "send chat", "/profile", "/pay", "/tip", "/backup", + "/restore", "Ctrl-Q", "?", ] { assert!(help.contains(needle), "help overlay missing {needle:?}"); } From 0ea6fd68c4d71b60f504cf416ce9436ff34dc928 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 23:32:06 -0400 Subject: [PATCH 2/2] [verified] feat(tui): fail-closed /file transfer (1 MiB) Typed fil frames chunk a local file to the selected friend over the existing one-shot pipe. Receive-side 1 MiB cap, basename-only names, hash check before rename, never overwrite. Chat shows [file] name only. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 10 +- src/dispatch.rs | 4 + src/file.rs | 387 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/node.rs | 54 +++++++ src/tui.rs | 34 +++- tests/file.rs | 133 +++++++++++++++ tests/tui_chrome.rs | 24 ++- 10 files changed, 641 insertions(+), 8 deletions(-) create mode 100644 src/file.rs create mode 100644 tests/file.rs diff --git a/Cargo.lock b/Cargo.lock index 910a24b..1f99c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2643,6 +2643,7 @@ dependencies = [ "rusqlite", "safelog", "serde_json", + "sha2", "sha3 0.10.9", "snow", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index f038b23..7285691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ serde_json = "1" argon2 = "0.5" chacha20poly1305 = "0.10" sha3 = "0.10" +sha2 = "0.10" md-5 = "0.10" rpassword = "7" diff --git a/README.md b/README.md index 6a0482f..b79b716 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,14 @@ 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. +## 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//`. Chat shows `[file] name (N bytes)`, +never raw frames. No outbox, no resume, no images in the TUI. + ## 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. @@ -188,7 +196,7 @@ Treat the backup file like the sqlite db. ## Mixed versions -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. +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. ## Wipe diff --git a/src/dispatch.rs b/src/dispatch.rs index 129ed22..c57a0bd 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -8,6 +8,7 @@ pub enum Kind { Invoice, Receipt, Ping, + File, Drop, } @@ -27,6 +28,9 @@ pub fn classify(pt: &[u8]) -> Kind { if pt.starts_with(b"png ") { return Kind::Ping; } + if pt.starts_with(b"fil ") { + return Kind::File; + } if pt.len() >= 4 && pt[0].is_ascii_lowercase() && pt[1].is_ascii_lowercase() diff --git a/src/file.rs b/src/file.rs new file mode 100644 index 0000000..9d4144d --- /dev/null +++ b/src/file.rs @@ -0,0 +1,387 @@ +//! 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 = std::result::Result; + +#[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 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, +} + +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)> { + 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> { + 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 { + 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 { + 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) -> Self { + Self { + root: root.into(), + inflight: HashMap::new(), + } + } + + pub fn ingest(&mut self, peer_fp: &str, chunk: &Chunk) -> Result> { + 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 { + 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 { + 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> { + 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() +} diff --git a/src/lib.rs b/src/lib.rs index 484841c..b7db84f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod backup; pub mod dispatch; +pub mod file; pub mod frame; pub mod hs; pub mod loc; diff --git a/src/node.rs b/src/node.rs index 4a8efac..ce6be8b 100644 --- a/src/node.rs +++ b/src/node.rs @@ -10,6 +10,7 @@ use tor_cell::relaycell::msg::Connected; use tor_hsservice::{RunningOnionService, handle_rend_requests}; use crate::dispatch::{self, Kind}; +use crate::file; use crate::frame; use crate::hs::{self, Client, HS_PORT}; use crate::loc; @@ -46,6 +47,7 @@ pub struct Node { keys: Mutex, wallet: Wallet, incoming_limit: Mutex, + inbox: Mutex, } impl Node { @@ -75,6 +77,7 @@ impl Node { .ok_or_else(|| "onion service disabled in config — fail closed".to_string())?; let (svc, rend) = launched; let onion = hs::onion_string(&svc)?; + let inbox = file::Inbox::new(home.join("inbox")); let node = Arc::new(Self { home, store: Mutex::new(store), @@ -84,6 +87,7 @@ impl Node { keys: Mutex::new(keys), wallet: Wallet::from_env(), incoming_limit: Mutex::new(TokenBucket::default()), + inbox: Mutex::new(inbox), }); // Accept rens before waiting so a reachability probe can succeed // while combined status is still Bootstrapping. @@ -481,6 +485,25 @@ 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 { + 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> { let (onion, prekey) = { let store = self.store.lock().map_err(|e| e.to_string())?; @@ -620,10 +643,41 @@ impl Node { } Kind::Invoice => self.ingest_invoice(&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(()), } } + 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> { let Some(inv) = pay::decode_invoice(pt) else { return Ok(()); diff --git a/src/tui.rs b/src/tui.rs index 88bd2c5..dda019d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -77,6 +77,7 @@ OnionWire keys\n\ /profile edit name, bio, Monero address\n\ /pay [memo] invoice to receive\n\ /tip [memo] pay selected friend\n\ + /file /path send file to selected friend (1 MiB)\n\ /backup /path encrypted identity export\n\ /restore /path overwrite self keys\n\ ? this help\n\ @@ -163,6 +164,7 @@ pub enum SlashCmd { Tip { atomic: String, memo: String }, Backup { path: String }, Restore { path: String }, + File { path: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -306,6 +308,11 @@ pub fn parse_cmd(raw: &str) -> Option { { 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 } @@ -715,6 +722,7 @@ impl App { let mut open_profile = false; let mut pay_cmd = None; let mut tip_cmd = None; + let mut file_cmd = None; let mut chat_send = None; match &mut self.screen { Screen::Main => match key.code { @@ -782,6 +790,10 @@ impl App { 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); @@ -943,6 +955,9 @@ impl App { if let Some((atomic, memo)) = tip_cmd { 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)?; } @@ -1197,6 +1212,23 @@ impl App { 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()); @@ -1341,7 +1373,7 @@ impl App { f.render_widget(chat, panes[1]); let cmd = if self.composer.is_empty() { - "/wipe /wipe-all /profile /who /pay /tip".to_string() + "/wipe /wipe-all /profile /who /pay /tip /file".to_string() } else { self.composer.clone() }; diff --git a/tests/file.rs b/tests/file.rs new file mode 100644 index 0000000..2372211 --- /dev/null +++ b/tests/file.rs @@ -0,0 +1,133 @@ +//! 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); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 5edd606..a0ada29 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -2,8 +2,8 @@ use onionwire::qr; use onionwire::tui::{ - banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, - onion_glyph, status_footer, wordmark_banner, Pane, + Pane, banner_for_width, compact_banner, draw_share, help_overlay_text, main_footer_hints, + onion_glyph, status_footer, wordmark_banner, }; #[test] @@ -40,8 +40,20 @@ fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); for needle in [ - "Tab", "F2", "F3", "F4", "F5", "send chat", "/profile", "/pay", "/tip", "/backup", - "/restore", "Ctrl-Q", "?", + "Tab", + "F2", + "F3", + "F4", + "F5", + "send chat", + "/profile", + "/pay", + "/tip", + "/file", + "/backup", + "/restore", + "Ctrl-Q", + "?", ] { assert!(help.contains(needle), "help overlay missing {needle:?}"); } @@ -65,10 +77,10 @@ fn banner_for_width_collapses_when_narrow() { #[test] fn chrome_renders_at_80x24_and_120x40() { + use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::layout::{Constraint, Layout}; use ratatui::widgets::Paragraph; - use ratatui::Terminal; let fp = "abcdef0123456789"; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; @@ -166,8 +178,8 @@ fn restore_terminal_is_callable_without_panic() { #[test] fn share_screen_shows_invite_not_qr_at_80x24() { - use ratatui::backend::TestBackend; use ratatui::Terminal; + use ratatui::backend::TestBackend; let sk = [7u8; 32]; let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion";