Compare commits

..

4 commits

Author SHA1 Message Date
af7d69ace8 Merge pull request 'feat(tui): fail-closed /file transfer (1 MiB)' (#16) from feat/tui-file-transfer into main
Some checks are pending
ci / test (push) Waiting to run
Reviewed-on: #16
2026-09-11 04:52:55 +00:00
Sirius DevOps
0ea6fd68c4
[verified] feat(tui): fail-closed /file transfer (1 MiB)
Some checks are pending
ci / test (pull_request) Waiting to run
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.
2026-09-10 23:32:06 -04:00
Sirius DevOps
c4ccca7e8c
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.
2026-09-10 23:07:22 -04:00
c141eb45b8 Merge pull request 'fix(sdk): enable rustls' ring provider — APK died at 'Failed to start / Could not determine the process-level CryptoProvider'' (#15) from wt/t_dcb8565a into main
All checks were successful
ci / test (push) Successful in 4m5s
Reviewed-on: #15
2026-09-10 22:35:31 -04:00
11 changed files with 743 additions and 18 deletions

1
Cargo.lock generated
View file

@ -2643,6 +2643,7 @@ dependencies = [
"rusqlite",
"safelog",
"serde_json",
"sha2",
"sha3 0.10.9",
"snow",
"tempfile",

View file

@ -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"

View file

@ -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 friends. Mouse-select the `onionwire:v1:…` line to copy.
Then `F2` to share your invite, `F3` to paste a friends. 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 friends invite |
| `F4` | Rotate **onion** (locator only) |
| `F5` | Selected friends 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 |
@ -156,6 +156,14 @@ Give that string to a friend. They `F3` paste it (`(o) paste invite`). Unknown `
If a peers 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` 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

View file

@ -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()

387
src/file.rs Normal file
View file

@ -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<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()
}

View file

@ -1,5 +1,6 @@
pub mod backup;
pub mod dispatch;
pub mod file;
pub mod frame;
pub mod hs;
pub mod loc;

View file

@ -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<Keys>,
wallet: Wallet,
incoming_limit: Mutex<TokenBucket>,
inbox: Mutex<file::Inbox>,
}
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<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> {
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(());

View file

@ -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\
@ -77,6 +77,7 @@ OnionWire keys\n\
/profile edit name, bio, Monero address\n\
/pay <xmr> [memo] invoice to receive\n\
/tip <xmr> [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,29 @@ pub enum SlashCmd {
Tip { atomic: String, memo: String },
Backup { 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)]
@ -284,6 +308,11 @@ pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
{
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
}
@ -693,6 +722,8 @@ 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 {
KeyCode::Tab => self.focus = self.focus.next(),
@ -718,8 +749,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 +758,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 +782,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 +790,18 @@ 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);
}
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 +955,12 @@ 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)?;
}
Ok(None)
}
@ -1163,6 +1212,36 @@ 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());
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();
@ -1294,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()
};

133
tests/file.rs Normal file
View file

@ -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);
}

45
tests/send.rs Normal file
View file

@ -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()))
);
}

View file

@ -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,7 +40,19 @@ 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",
"/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";