feat: signed friend profile frames and TUI /profile /who
prf frames are ed25519-signed, friend-visible only (unknown pubkey is ignored). /profile edits self; F5 and /who show the selected friend's last profile.
This commit is contained in:
parent
ef78be4035
commit
d304cc4c3b
7 changed files with 732 additions and 22 deletions
|
|
@ -3,9 +3,10 @@ pub mod frame;
|
||||||
pub mod hs;
|
pub mod hs;
|
||||||
pub mod loc;
|
pub mod loc;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
|
pub mod profile;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
mod store;
|
mod store;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|
||||||
pub use store::{Friend, Message, SelfIdentity, Store};
|
pub use store::{Friend, FriendProfile, Message, SelfIdentity, Store};
|
||||||
|
|
|
||||||
76
src/node.rs
76
src/node.rs
|
|
@ -13,9 +13,10 @@ use crate::dispatch::{self, Kind};
|
||||||
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;
|
||||||
|
use crate::profile;
|
||||||
use crate::qr;
|
use crate::qr;
|
||||||
use crate::session::{self, Keys};
|
use crate::session::{self, Keys};
|
||||||
use crate::store::{Friend, Message, Store};
|
use crate::store::{Friend, FriendProfile, Message, Store};
|
||||||
|
|
||||||
pub struct RotateResult {
|
pub struct RotateResult {
|
||||||
pub notified: usize,
|
pub notified: usize,
|
||||||
|
|
@ -150,6 +151,57 @@ impl Node {
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn self_profile(&self) -> Result<FriendProfile, String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.self_profile()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>, String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.friend_profile(pubkey)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_self_profile(
|
||||||
|
&self,
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
xmr_addr: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.set_self_profile(display_name, bio, xmr_addr)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot signed `prf` to a friend. Fail closed; no retry, no outbox.
|
||||||
|
pub async fn push_self_profile(&self, friend_pk: &[u8]) -> Result<(), String> {
|
||||||
|
let me = self.self_profile()?;
|
||||||
|
let prf = profile::sign(
|
||||||
|
&self.keys.identity_sk,
|
||||||
|
&me.display_name,
|
||||||
|
&me.bio,
|
||||||
|
&me.xmr_addr,
|
||||||
|
me.updated_at,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let pt = profile::encode(&prf);
|
||||||
|
let (onion, prekey) = {
|
||||||
|
let f = self.friend(friend_pk)?;
|
||||||
|
if f.prekey.len() != 32 {
|
||||||
|
return Err("friend missing prekey".into());
|
||||||
|
}
|
||||||
|
(f.onion, f.prekey)
|
||||||
|
};
|
||||||
|
self.try_send(&onion, friend_pk, &prekey, &pt).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
Duration::from_secs(20),
|
Duration::from_secs(20),
|
||||||
|
|
@ -320,7 +372,25 @@ impl Node {
|
||||||
);
|
);
|
||||||
match applied {
|
match applied {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => eprintln!("loc dropped (bad sig, stale ts, or unknown friend)"),
|
Ok(false) => {
|
||||||
|
eprintln!("loc dropped (bad sig, stale ts, or unknown friend)")
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Kind::Profile => {
|
||||||
|
if let Some(prf) = profile::decode(&pt) {
|
||||||
|
let applied = store
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.apply_profile(&sess.peer_identity, &prf);
|
||||||
|
match applied {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
eprintln!("prf dropped (bad sig, stale ts, or unknown friend)")
|
||||||
|
}
|
||||||
Err(e) => return Err(e.to_string()),
|
Err(e) => return Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +404,7 @@ impl Node {
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Kind::Profile | Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()),
|
Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
src/profile.rs
Normal file
157
src/profile.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub display_name: String,
|
||||||
|
pub bio: String,
|
||||||
|
pub xmr_addr: String,
|
||||||
|
pub ts: i64,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX: &[u8] = b"prf ";
|
||||||
|
const NAME_MAX: usize = 64;
|
||||||
|
const BIO_MAX: usize = 512;
|
||||||
|
|
||||||
|
pub fn validate(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||||
|
check_fields(display_name, bio, xmr_addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(
|
||||||
|
identity_sk: &[u8],
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
xmr_addr: &str,
|
||||||
|
ts: i64,
|
||||||
|
) -> Result<Profile> {
|
||||||
|
check_fields(display_name, bio, xmr_addr)?;
|
||||||
|
let sk_bytes: [u8; 32] = identity_sk
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error("identity secret key must be 32 bytes".into()))?;
|
||||||
|
let sk = SigningKey::from_bytes(&sk_bytes);
|
||||||
|
let sig = sk
|
||||||
|
.sign(&sign_msg(display_name, bio, xmr_addr, ts))
|
||||||
|
.to_bytes()
|
||||||
|
.to_vec();
|
||||||
|
Ok(Profile {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
bio: bio.to_string(),
|
||||||
|
xmr_addr: xmr_addr.to_string(),
|
||||||
|
ts,
|
||||||
|
sig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(identity_pk: &[u8], prf: &Profile) -> bool {
|
||||||
|
if check_fields(&prf.display_name, &prf.bio, &prf.xmr_addr).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if identity_pk.len() != 32 || prf.sig.len() != 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pk: [u8; 32] = match identity_pk.try_into() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let sig: [u8; 64] = match prf.sig.as_slice().try_into() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let Ok(vk) = VerifyingKey::from_bytes(&pk) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
vk.verify(
|
||||||
|
&sign_msg(&prf.display_name, &prf.bio, &prf.xmr_addr, prf.ts),
|
||||||
|
&Signature::from_bytes(&sig),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(prf: &Profile) -> Vec<u8> {
|
||||||
|
let mut out = Vec::from(PREFIX);
|
||||||
|
out.extend_from_slice(prf.display_name.as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(prf.bio.as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(prf.xmr_addr.as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(prf.ts.to_string().as_bytes());
|
||||||
|
out.push(b'\n');
|
||||||
|
out.extend_from_slice(to_hex(&prf.sig).as_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(pt: &[u8]) -> Option<Profile> {
|
||||||
|
let rest = pt.strip_prefix(PREFIX)?;
|
||||||
|
let text = std::str::from_utf8(rest).ok()?;
|
||||||
|
let mut parts = text.splitn(5, '\n');
|
||||||
|
let display_name = parts.next()?.to_string();
|
||||||
|
let bio = parts.next()?.to_string();
|
||||||
|
let xmr_addr = parts.next()?.to_string();
|
||||||
|
let ts = parts.next()?.parse::<i64>().ok()?;
|
||||||
|
let sig = from_hex(parts.next()?)?;
|
||||||
|
if check_fields(&display_name, &bio, &xmr_addr).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Profile {
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
xmr_addr,
|
||||||
|
ts,
|
||||||
|
sig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_fields(display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||||
|
if display_name.len() > NAME_MAX {
|
||||||
|
return Err(Error("display_name longer than 64 bytes".into()));
|
||||||
|
}
|
||||||
|
if bio.len() > BIO_MAX {
|
||||||
|
return Err(Error("bio longer than 512 bytes".into()));
|
||||||
|
}
|
||||||
|
if display_name.contains('\n') || bio.contains('\n') || xmr_addr.contains('\n') {
|
||||||
|
return Err(Error("profile fields must not contain newlines".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_msg(display_name: &str, bio: &str, xmr_addr: &str, ts: i64) -> Vec<u8> {
|
||||||
|
let mut msg = Vec::from(display_name.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(bio.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(xmr_addr.as_bytes());
|
||||||
|
msg.push(b'\n');
|
||||||
|
msg.extend_from_slice(ts.to_string().as_bytes());
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
104
src/store.rs
104
src/store.rs
|
|
@ -54,6 +54,13 @@ pub struct Friend {
|
||||||
pub prekey: Vec<u8>,
|
pub prekey: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FriendProfile {
|
||||||
|
pub display_name: String,
|
||||||
|
pub bio: String,
|
||||||
|
pub xmr_addr: String,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub dir: String,
|
pub dir: String,
|
||||||
|
|
@ -105,6 +112,21 @@ impl Store {
|
||||||
prekey_pk BLOB NOT NULL,
|
prekey_pk BLOB NOT NULL,
|
||||||
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
|
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS self_profile (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
bio TEXT NOT NULL DEFAULT '',
|
||||||
|
xmr_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS friend_profiles (
|
||||||
|
pubkey BLOB PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
bio TEXT NOT NULL,
|
||||||
|
xmr_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
|
||||||
",
|
",
|
||||||
)?;
|
)?;
|
||||||
let store = Self { conn };
|
let store = Self { conn };
|
||||||
|
|
@ -304,6 +326,79 @@ impl Store {
|
||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> {
|
||||||
|
crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?;
|
||||||
|
let prev: i64 = self.conn.query_row(
|
||||||
|
"SELECT updated_at FROM self_profile WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
let now = unix_now().max(prev + 1);
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1",
|
||||||
|
params![display_name, bio, xmr_addr, now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn self_profile(&self) -> Result<FriendProfile> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1",
|
||||||
|
[],
|
||||||
|
profile_from_row,
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert.
|
||||||
|
pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result<bool> {
|
||||||
|
if !crate::profile::verify(pubkey, prf) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let exists: i64 = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM friends WHERE pubkey = ?1",
|
||||||
|
params![pubkey],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if exists == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let prev: Option<i64> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT updated_at FROM friend_profiles WHERE pubkey = ?1",
|
||||||
|
params![pubkey],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if prev.is_some_and(|t| prf.ts <= t) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(pubkey) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
bio = excluded.bio,
|
||||||
|
xmr_addr = excluded.xmr_addr,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts],
|
||||||
|
)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn friend_profile(&self, pubkey: &[u8]) -> Result<Option<FriendProfile>> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1",
|
||||||
|
params![pubkey],
|
||||||
|
profile_from_row,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
|
pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> {
|
||||||
let n = self.conn.execute(
|
let n = self.conn.execute(
|
||||||
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
|
"UPDATE friends SET prekey = ?1 WHERE pubkey = ?2",
|
||||||
|
|
@ -385,6 +480,15 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FriendProfile> {
|
||||||
|
Ok(FriendProfile {
|
||||||
|
display_name: row.get(0)?,
|
||||||
|
bio: row.get(1)?,
|
||||||
|
xmr_addr: row.get(2)?,
|
||||||
|
updated_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
||||||
Ok(Friend {
|
Ok(Friend {
|
||||||
pubkey: row.get(0)?,
|
pubkey: row.get(0)?,
|
||||||
|
|
|
||||||
251
src/tui.rs
251
src/tui.rs
|
|
@ -9,7 +9,7 @@ use ratatui::{DefaultTerminal, Frame};
|
||||||
|
|
||||||
use crate::node::Node;
|
use crate::node::Node;
|
||||||
use crate::qr::{self, QrPayload};
|
use crate::qr::{self, QrPayload};
|
||||||
use crate::store::Friend;
|
use crate::store::{Friend, FriendProfile};
|
||||||
|
|
||||||
const C_ACCENT: Color = Color::Rgb(232, 165, 75);
|
const C_ACCENT: Color = Color::Rgb(232, 165, 75);
|
||||||
const C_TEXT: Color = Color::Rgb(196, 214, 176);
|
const C_TEXT: Color = Color::Rgb(196, 214, 176);
|
||||||
|
|
@ -59,17 +59,47 @@ 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 /wipe /wipe-all from composer\n\
|
Enter /wipe /wipe-all /profile /who\n\
|
||||||
Esc close overlay, go back, clear composer\n\
|
Esc close overlay, go back, clear composer\n\
|
||||||
F2 share QR\n\
|
F2 share QR\n\
|
||||||
F3 paste a friend QR\n\
|
F3 paste a friend QR\n\
|
||||||
F4 rotate onion locator\n\
|
F4 rotate onion locator\n\
|
||||||
|
F5 selected friend's profile (/who)\n\
|
||||||
|
/profile edit name, bio, Monero address\n\
|
||||||
? this help\n\
|
? this help\n\
|
||||||
Ctrl-Q quit\n\
|
Ctrl-Q quit\n\
|
||||||
\n\
|
\n\
|
||||||
Esc closes this overlay"
|
Esc closes this overlay"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn who_overlay_text(fingerprint: &str, onion: &str, profile: Option<&FriendProfile>) -> String {
|
||||||
|
match profile {
|
||||||
|
None => format!(
|
||||||
|
"friend profile\n\nfp {fingerprint}\nonion {onion}\n\nno profile yet\n\nEsc closes"
|
||||||
|
),
|
||||||
|
Some(p) => {
|
||||||
|
let name = if p.display_name.is_empty() {
|
||||||
|
"(none)"
|
||||||
|
} else {
|
||||||
|
p.display_name.as_str()
|
||||||
|
};
|
||||||
|
let bio = if p.bio.is_empty() {
|
||||||
|
"(none)"
|
||||||
|
} else {
|
||||||
|
p.bio.as_str()
|
||||||
|
};
|
||||||
|
let xmr = if p.xmr_addr.is_empty() {
|
||||||
|
"(none)"
|
||||||
|
} else {
|
||||||
|
p.xmr_addr.as_str()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"friend profile\n\nname {name}\nbio {bio}\nxmr {xmr}\nfp {fingerprint}\nonion {onion}\n\nEsc closes"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn banner_for_width(width: u16) -> &'static str {
|
pub fn banner_for_width(width: u16) -> &'static str {
|
||||||
let full = wordmark_banner();
|
let full = wordmark_banner();
|
||||||
let max_line = full.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
let max_line = full.lines().map(|l| l.chars().count()).max().unwrap_or(0);
|
||||||
|
|
@ -111,6 +141,90 @@ pub enum WipeKind {
|
||||||
All,
|
All,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SlashCmd {
|
||||||
|
Wipe(WipeKind),
|
||||||
|
Profile,
|
||||||
|
Who,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProfileDraft {
|
||||||
|
pub display_name: String,
|
||||||
|
pub bio: String,
|
||||||
|
pub xmr_addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProfileEditor {
|
||||||
|
name: String,
|
||||||
|
bio: String,
|
||||||
|
xmr: String,
|
||||||
|
field: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileEditor {
|
||||||
|
pub fn new(display_name: &str, bio: &str, xmr_addr: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: display_name.to_string(),
|
||||||
|
bio: bio.to_string(),
|
||||||
|
xmr: xmr_addr.to_string(),
|
||||||
|
field: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_enter(&self) -> ProfileDraft {
|
||||||
|
ProfileDraft {
|
||||||
|
display_name: self.name.clone(),
|
||||||
|
bio: self.bio.clone(),
|
||||||
|
xmr_addr: self.xmr.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_tab(&mut self) {
|
||||||
|
self.field = (self.field + 1) % 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_backtab(&mut self) {
|
||||||
|
self.field = (self.field + 2) % 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_char(&mut self, c: char) {
|
||||||
|
if c == '\n' {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.field_mut().push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_backspace(&mut self) {
|
||||||
|
self.field_mut().pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field(&self) -> usize {
|
||||||
|
self.field
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bio(&self) -> &str {
|
||||||
|
&self.bio
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn xmr_addr(&self) -> &str {
|
||||||
|
&self.xmr
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_mut(&mut self) -> &mut String {
|
||||||
|
match self.field {
|
||||||
|
0 => &mut self.name,
|
||||||
|
1 => &mut self.bio,
|
||||||
|
_ => &mut self.xmr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum WipeDecision {
|
pub enum WipeDecision {
|
||||||
Pending,
|
Pending,
|
||||||
|
|
@ -124,10 +238,19 @@ pub enum AppExit {
|
||||||
WipeAll,
|
WipeAll,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
|
||||||
match raw.trim() {
|
match raw.trim() {
|
||||||
"/wipe" => Some(WipeKind::Messages),
|
"/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)),
|
||||||
"/wipe-all" => Some(WipeKind::All),
|
"/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)),
|
||||||
|
"/profile" => Some(SlashCmd::Profile),
|
||||||
|
"/who" => Some(SlashCmd::Who),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||||
|
match parse_cmd(raw) {
|
||||||
|
Some(SlashCmd::Wipe(k)) => Some(k),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +385,7 @@ struct App {
|
||||||
composer: String,
|
composer: String,
|
||||||
focus: Pane,
|
focus: Pane,
|
||||||
help_open: bool,
|
help_open: bool,
|
||||||
|
who_open: bool,
|
||||||
chat_scroll: u16,
|
chat_scroll: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +396,7 @@ enum Screen {
|
||||||
Approve { payload: QrPayload },
|
Approve { payload: QrPayload },
|
||||||
Rotate { prompt: RotatePrompt },
|
Rotate { prompt: RotatePrompt },
|
||||||
Wipe { kind: WipeKind, prompt: WipePrompt },
|
Wipe { kind: WipeKind, prompt: WipePrompt },
|
||||||
|
Profile { editor: ProfileEditor },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -290,6 +415,7 @@ impl App {
|
||||||
composer: String::new(),
|
composer: String::new(),
|
||||||
focus: Pane::Composer,
|
focus: Pane::Composer,
|
||||||
help_open: false,
|
help_open: false,
|
||||||
|
who_open: false,
|
||||||
chat_scroll: 0,
|
chat_scroll: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -320,12 +446,25 @@ impl App {
|
||||||
}
|
}
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
if self.who_open {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::F(5) => self.who_open = false,
|
||||||
|
KeyCode::Char('?') => {
|
||||||
|
self.who_open = false;
|
||||||
|
self.help_open = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
if key.code == KeyCode::Char('?') && !matches!(self.screen, Screen::Paste { .. }) {
|
if key.code == KeyCode::Char('?') && !matches!(self.screen, Screen::Paste { .. }) {
|
||||||
self.help_open = true;
|
self.help_open = true;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut wipe_confirm = None;
|
let mut wipe_confirm = None;
|
||||||
|
let mut profile_save = None;
|
||||||
|
let mut open_profile = false;
|
||||||
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(),
|
||||||
|
|
@ -344,14 +483,15 @@ impl App {
|
||||||
prompt: RotatePrompt::new(),
|
prompt: RotatePrompt::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
KeyCode::F(5) => self.who_open = true,
|
||||||
KeyCode::Esc => self.composer.clear(),
|
KeyCode::Esc => self.composer.clear(),
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
if self.focus == Pane::Composer {
|
if self.focus == Pane::Composer {
|
||||||
self.composer.pop();
|
self.composer.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => match parse_cmd(&self.composer) {
|
||||||
if let Some(kind) = parse_slash(&self.composer) {
|
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(),
|
||||||
|
|
@ -359,7 +499,16 @@ impl App {
|
||||||
};
|
};
|
||||||
self.screen = Screen::Wipe { kind, prompt };
|
self.screen = Screen::Wipe { kind, prompt };
|
||||||
}
|
}
|
||||||
|
Some(SlashCmd::Profile) => {
|
||||||
|
self.composer.clear();
|
||||||
|
open_profile = true;
|
||||||
}
|
}
|
||||||
|
Some(SlashCmd::Who) => {
|
||||||
|
self.composer.clear();
|
||||||
|
self.who_open = true;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
},
|
||||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.handle_main_char(c);
|
self.handle_main_char(c);
|
||||||
}
|
}
|
||||||
|
|
@ -421,10 +570,27 @@ impl App {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Screen::Profile { editor } => match key.code {
|
||||||
|
KeyCode::Esc => self.screen = Screen::Main,
|
||||||
|
KeyCode::Tab => editor.on_tab(),
|
||||||
|
KeyCode::BackTab => editor.on_backtab(),
|
||||||
|
KeyCode::Backspace => editor.on_backspace(),
|
||||||
|
KeyCode::Enter => profile_save = Some(editor.on_enter()),
|
||||||
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
editor.on_char(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if let Some(kind) = wipe_confirm {
|
if let Some(kind) = wipe_confirm {
|
||||||
return self.confirm_wipe(kind);
|
return self.confirm_wipe(kind);
|
||||||
}
|
}
|
||||||
|
if open_profile {
|
||||||
|
self.open_profile()?;
|
||||||
|
}
|
||||||
|
if let Some(draft) = profile_save {
|
||||||
|
self.save_profile(draft)?;
|
||||||
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,6 +739,43 @@ impl App {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_profile(&mut self) -> Result<(), String> {
|
||||||
|
let p = self.node.self_profile()?;
|
||||||
|
self.screen = Screen::Profile {
|
||||||
|
editor: ProfileEditor::new(&p.display_name, &p.bio, &p.xmr_addr),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_profile(&mut self, draft: ProfileDraft) -> Result<(), String> {
|
||||||
|
if let Err(e) = self
|
||||||
|
.node
|
||||||
|
.set_self_profile(&draft.display_name, &draft.bio, &draft.xmr_addr)
|
||||||
|
{
|
||||||
|
self.alert = Some(e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.screen = Screen::Main;
|
||||||
|
if let Some(friend) = self.friends.get(self.selected) {
|
||||||
|
let pk = friend.pubkey.clone();
|
||||||
|
match self.rt.block_on(self.node.push_self_profile(&pk)) {
|
||||||
|
Ok(()) => self.status_note = Some("profile saved · sent".into()),
|
||||||
|
Err(e) => self.status_note = Some(format!("profile send failed: {e}")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.status_note = Some("profile saved".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn who_body(&self) -> String {
|
||||||
|
let Some(friend) = self.friends.get(self.selected) else {
|
||||||
|
return "no friend selected\n\nEsc closes".into();
|
||||||
|
};
|
||||||
|
let profile = self.node.friend_profile(&friend.pubkey).ok().flatten();
|
||||||
|
who_overlay_text(&friend.fingerprint, &friend.onion, profile.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&self, f: &mut Frame) {
|
fn draw(&self, f: &mut Frame) {
|
||||||
match &self.screen {
|
match &self.screen {
|
||||||
Screen::Main => self.draw_main(f),
|
Screen::Main => self.draw_main(f),
|
||||||
|
|
@ -581,9 +784,12 @@ impl App {
|
||||||
Screen::Approve { payload } => draw_approve(f, payload),
|
Screen::Approve { payload } => draw_approve(f, payload),
|
||||||
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
||||||
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
||||||
|
Screen::Profile { editor } => draw_profile(f, editor),
|
||||||
}
|
}
|
||||||
if self.help_open {
|
if self.help_open {
|
||||||
draw_help_overlay(f);
|
draw_text_overlay(f, "? help", help_overlay_text());
|
||||||
|
} else if self.who_open {
|
||||||
|
draw_text_overlay(f, "/who", &self.who_body());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,7 +894,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".to_string()
|
"/wipe /wipe-all /profile /who".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.composer.clone()
|
self.composer.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -803,7 +1009,7 @@ fn join_footer(parts: &[&str]) -> String {
|
||||||
|
|
||||||
/// Main-screen key hints. Kept short so width 80 still shows TOR + identity + `? help`.
|
/// Main-screen key hints. Kept short so width 80 still shows TOR + identity + `? help`.
|
||||||
pub fn main_footer_hints() -> &'static str {
|
pub fn main_footer_hints() -> &'static str {
|
||||||
"Tab F2 F3 F4 ? help Ctrl-Q"
|
"Tab F2 F3 F4 F5 ? help Ctrl-Q"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn footer_hints(screen: &Screen) -> &'static str {
|
fn footer_hints(screen: &Screen) -> &'static str {
|
||||||
|
|
@ -817,6 +1023,7 @@ fn footer_hints(screen: &Screen) -> &'static str {
|
||||||
WipeKind::Messages => "type WIPE Esc cancel ? help",
|
WipeKind::Messages => "type WIPE Esc cancel ? help",
|
||||||
WipeKind::All => "type WIPEALL Esc cancel ? help",
|
WipeKind::All => "type WIPEALL Esc cancel ? help",
|
||||||
},
|
},
|
||||||
|
Screen::Profile { .. } => "Enter save Tab field Esc cancel ? help",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,9 +1105,27 @@ fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_help_overlay(f: &mut Frame) {
|
fn draw_profile(f: &mut Frame, editor: &ProfileEditor) {
|
||||||
|
let labels = ["name", "bio", "xmr"];
|
||||||
|
let values = [editor.display_name(), editor.bio(), editor.xmr_addr()];
|
||||||
|
let mut body = String::from(
|
||||||
|
"Your profile (friend-visible)\nEnter saves Esc cancels Tab next field\n",
|
||||||
|
);
|
||||||
|
for (i, (label, value)) in labels.iter().zip(values).enumerate() {
|
||||||
|
let mark = if editor.field() == i { ">" } else { " " };
|
||||||
|
body.push_str(&format!("\n{mark} {label}: {value}"));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(body)
|
||||||
|
.style(Style::default().fg(C_TEXT))
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.block(themed_block("(o) profile")),
|
||||||
|
f.area(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_text_overlay(f: &mut Frame, title: &str, text: &str) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let text = help_overlay_text();
|
|
||||||
let lines = text.lines().count() as u16 + 2;
|
let lines = text.lines().count() as u16 + 2;
|
||||||
let text_w = text
|
let text_w = text
|
||||||
.lines()
|
.lines()
|
||||||
|
|
@ -920,7 +1145,7 @@ fn draw_help_overlay(f: &mut Frame) {
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(text)
|
Paragraph::new(text)
|
||||||
.style(Style::default().fg(C_TEXT))
|
.style(Style::default().fg(C_TEXT))
|
||||||
.block(themed_block("? help")),
|
.block(themed_block(title)),
|
||||||
popup,
|
popup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
tests/profile.rs
Normal file
153
tests/profile.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
//! M7: signed friend-visible profile frames + slash parse.
|
||||||
|
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use onionwire::Store;
|
||||||
|
use onionwire::profile::{self};
|
||||||
|
use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
fn store() -> (tempfile::TempDir, Store) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = Store::open_at(dir.path()).expect("open");
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_profile_upserts_friend_row_keeps_fingerprint() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let pk = sk.verifying_key().to_bytes();
|
||||||
|
store
|
||||||
|
.upsert_friend(&pk, "alice.onion", Some("alice"))
|
||||||
|
.unwrap();
|
||||||
|
let before = store.get_friend(&pk).unwrap().unwrap();
|
||||||
|
|
||||||
|
let prf = profile::sign(&sk.to_bytes(), "Ali", "hi", "", 2_000_000_000).expect("sign");
|
||||||
|
assert!(
|
||||||
|
store.apply_profile(&pk, &prf).unwrap(),
|
||||||
|
"newer signed profile must apply"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = store.get_friend(&pk).unwrap().unwrap();
|
||||||
|
assert_eq!(after.fingerprint, before.fingerprint);
|
||||||
|
assert_eq!(after.petname.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(after.onion, "alice.onion");
|
||||||
|
assert_eq!(store.friend_count().unwrap(), 1);
|
||||||
|
|
||||||
|
let got = store.friend_profile(&pk).unwrap().expect("row");
|
||||||
|
assert_eq!(got.display_name, "Ali");
|
||||||
|
assert_eq!(got.bio, "hi");
|
||||||
|
assert_eq!(got.xmr_addr, "");
|
||||||
|
assert_eq!(got.updated_at, 2_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_profile_does_not_change_row() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let owner = SigningKey::generate(&mut OsRng);
|
||||||
|
let owner_pk = owner.verifying_key().to_bytes();
|
||||||
|
store.upsert_friend(&owner_pk, "old.onion", None).unwrap();
|
||||||
|
let first = profile::sign(&owner.to_bytes(), "real", "", "", 2_000_000_000).unwrap();
|
||||||
|
assert!(store.apply_profile(&owner_pk, &first).unwrap());
|
||||||
|
|
||||||
|
let other = SigningKey::generate(&mut OsRng);
|
||||||
|
let spoof = profile::sign(&other.to_bytes(), "evil", "no", "", 2_000_000_001).unwrap();
|
||||||
|
assert!(!store.apply_profile(&owner_pk, &spoof).unwrap());
|
||||||
|
let got = store.friend_profile(&owner_pk).unwrap().unwrap();
|
||||||
|
assert_eq!(got.display_name, "real");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_ts_profile_does_not_change_row() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let pk = sk.verifying_key().to_bytes();
|
||||||
|
store.upsert_friend(&pk, "old.onion", None).unwrap();
|
||||||
|
let newer = profile::sign(&sk.to_bytes(), "new", "", "", 100).unwrap();
|
||||||
|
assert!(store.apply_profile(&pk, &newer).unwrap());
|
||||||
|
let stale = profile::sign(&sk.to_bytes(), "old", "", "", 50).unwrap();
|
||||||
|
assert!(!store.apply_profile(&pk, &stale).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
store.friend_profile(&pk).unwrap().unwrap().display_name,
|
||||||
|
"new"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_pubkey_profile_is_ignored() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let pk = sk.verifying_key().to_bytes();
|
||||||
|
let prf = profile::sign(&sk.to_bytes(), "ghost", "", "", 2_000_000_000).unwrap();
|
||||||
|
assert!(!store.apply_profile(&pk, &prf).unwrap());
|
||||||
|
assert!(store.friend_profile(&pk).unwrap().is_none());
|
||||||
|
assert_eq!(store.friend_count().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bio_over_512_rejected() {
|
||||||
|
let bio = "x".repeat(513);
|
||||||
|
let sk = [7u8; 32];
|
||||||
|
assert!(profile::sign(&sk, "n", &bio, "", 1).is_err());
|
||||||
|
let mut out = b"prf n\n".to_vec();
|
||||||
|
out.extend_from_slice(bio.as_bytes());
|
||||||
|
out.extend_from_slice(b"\n\n1\n");
|
||||||
|
out.extend_from_slice("aa".repeat(64).as_bytes());
|
||||||
|
assert!(profile::decode(&out).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newline_in_name_rejected() {
|
||||||
|
let sk = [7u8; 32];
|
||||||
|
assert!(profile::sign(&sk, "bad\nname", "bio", "", 1).is_err());
|
||||||
|
assert!(profile::decode(b"prf bad\nname\nbio\n\n1\n00").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_is_not_profile() {
|
||||||
|
assert!(profile::decode(b"hello wire").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_profile_roundtrip() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
store.set_self_profile("me", "a bio", "4abc").unwrap();
|
||||||
|
let got = store.self_profile().unwrap();
|
||||||
|
assert_eq!(got.display_name, "me");
|
||||||
|
assert_eq!(got.bio, "a bio");
|
||||||
|
assert_eq!(got.xmr_addr, "4abc");
|
||||||
|
assert!(got.updated_at > 0);
|
||||||
|
store.set_self_profile("", "", "").unwrap();
|
||||||
|
let empty = store.self_profile().unwrap();
|
||||||
|
assert_eq!(empty.display_name, "");
|
||||||
|
assert!(empty.updated_at >= got.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_profile_who_and_wipe_still_parse() {
|
||||||
|
assert_eq!(parse_cmd("/profile"), Some(SlashCmd::Profile));
|
||||||
|
assert_eq!(parse_cmd(" /profile "), Some(SlashCmd::Profile));
|
||||||
|
assert_eq!(parse_cmd("/who"), Some(SlashCmd::Who));
|
||||||
|
assert_eq!(parse_cmd("/who\n"), Some(SlashCmd::Who));
|
||||||
|
assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages)));
|
||||||
|
assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages));
|
||||||
|
assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All));
|
||||||
|
assert_eq!(parse_slash("/profile"), None);
|
||||||
|
assert_eq!(parse_cmd("/rotate"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_save_empty_name_ok_no_confirm_phrase() {
|
||||||
|
let saved = onionwire::tui::ProfileEditor::new("", "", "").on_enter();
|
||||||
|
assert_eq!(saved.display_name, "");
|
||||||
|
assert_eq!(saved.bio, "");
|
||||||
|
assert_eq!(saved.xmr_addr, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_empty_says_no_profile_yet() {
|
||||||
|
let t = onionwire::tui::who_overlay_text("abcd", "x.onion", None);
|
||||||
|
assert!(t.contains("no profile yet"));
|
||||||
|
assert!(t.contains("abcd"));
|
||||||
|
assert!(t.contains("x.onion"));
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
|
//! Main-screen chrome helpers: ASCII wordmark, compact fallback, help text.
|
||||||
|
|
||||||
use onionwire::tui::{
|
use onionwire::tui::{
|
||||||
banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
|
Pane, banner_for_width, compact_banner, help_overlay_text, main_footer_hints, onion_glyph,
|
||||||
status_footer, wordmark_banner, Pane,
|
status_footer, wordmark_banner,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -38,7 +38,7 @@ fn onion_glyph_is_nonempty() {
|
||||||
fn help_overlay_lists_core_bindings() {
|
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 ["Tab", "F2", "F3", "F4", "Ctrl-Q", "?"] {
|
for needle in ["Tab", "F2", "F3", "F4", "F5", "/profile", "Ctrl-Q", "?"] {
|
||||||
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
||||||
}
|
}
|
||||||
for line in help.lines() {
|
for line in help.lines() {
|
||||||
|
|
@ -61,10 +61,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 = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
let onion = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopq.onion";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue