use std::collections::BTreeSet;
use std::future::Future;
use std::io::IsTerminal;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use clap::Parser;
use ferry_cli::{Cli, Command, DaemonArgs, PeersCommand};
use ferry_core::{
AppConfig, DaemonConfig, DirectPeer, KnownPeerEntry, NativeAnnouncement, NativeAnnouncer,
NativeIdentity, PeerLookup, PeerRecord, PeerRegistry, ReceiveRequest, SendRequest,
TransferControl, TransferDirection, TransferEvent, TrustState, TrustStore,
discover_native_peers, receive_direct_file, receive_direct_file_with_control, send_direct_file,
send_direct_file_with_control, validate_send_paths,
};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let json = cli.json;
if let Err(error) = run(cli).await {
let code = exit_code(&error);
if json {
print_json(&ErrorEvent {
event: "error",
message: &error.to_string(),
exit_code: code,
});
}
eprintln!("error: {error}");
std::process::exit(code);
}
}
async fn run(cli: Cli) -> ferry_core::Result<()> {
let quiet = cli.quiet;
let json = cli.json;
let discovery_enabled = !cli.no_discovery;
match cli.command {
Some(Command::Send(args)) => {
let peer =
resolve_send_peer(&args.peer, args.fingerprint.as_deref(), discovery_enabled)
.await?;
validate_send_paths(&args.paths)?;
let request = SendRequest::new(peer, args.paths)?;
let identity = NativeIdentity::load_or_generate()?;
let mut output = CommandOutput::new(quiet, json);
let control = TransferControl::new();
let summary = run_cancellable(
control.clone(),
send_direct_file_with_control(&request, &identity, control, |event| {
output.update(event)
}),
)
.await?;
output.finish();
if !quiet && !json {
print_summary("sent", &summary);
}
}
Some(Command::Recv(args)) => {
let listen = args
.listen
.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 53318)));
let request = ReceiveRequest::new(listen);
let identity = NativeIdentity::load_or_generate()?;
let _announcer = if discovery_enabled {
let config = AppConfig::load_or_default()?;
let mut announcement = NativeAnnouncement::from_config(&config, &identity);
announcement.quic_port = request.listen().port();
Some(NativeAnnouncer::start(&announcement)?)
} else {
None
};
if !quiet && !json {
eprintln!(
"receiving on {} as {}",
request.listen(),
short_fingerprint(identity.fingerprint())
);
}
let destination = match args.dest {
Some(dest) => dest,
None => std::env::current_dir()?,
};
let mut output = CommandOutput::new(quiet, json);
let control = TransferControl::new();
let summary = run_cancellable(
control.clone(),
receive_direct_file_with_control(
&request,
destination,
&identity,
control,
|event| output.update(event),
),
)
.await?;
output.finish();
if !quiet && !json {
print_summary("received", &summary);
}
let _ = args.accept_all;
}
Some(Command::Peers { command }) => match command {
Some(PeersCommand::Trust { fingerprint }) => {
let mut store = TrustStore::load_or_default()?;
let registry = match store.trust_fingerprint(fingerprint.clone()) {
Ok(entry) => {
let entry = entry.clone();
store.save_default_path()?;
if json {
print_json(&PeerTrustedEvent {
event: "peer_trusted",
fingerprint: &entry.fingerprint,
});
} else if !quiet {
eprintln!("trusted peer {}", short_fingerprint(&entry.fingerprint));
}
return Ok(());
}
Err(error) if discovery_enabled => {
let registry = discover_native_peers(Duration::from_secs(2)).await?;
if registry.records().next().is_none() {
return Err(error);
}
registry
}
Err(error) => return Err(error),
};
let entry = trust_peer_query(&mut store, &fingerprint, Some(®istry))?;
store.save_default_path()?;
if json {
print_json(&PeerTrustedEvent {
event: "peer_trusted",
fingerprint: &entry.fingerprint,
});
} else if !quiet {
eprintln!("trusted peer {}", short_fingerprint(&entry.fingerprint));
}
}
Some(PeersCommand::Forget { fingerprint }) => {
let mut store = TrustStore::load_or_default()?;
let removed = store.forget(&fingerprint);
store.save_default_path()?;
if json {
print_json(&PeerForgottenEvent {
event: "peer_forgotten",
fingerprint: &fingerprint,
removed,
});
} else if !quiet {
if removed {
eprintln!("forgot peer {}", short_fingerprint(&fingerprint));
} else {
eprintln!("peer {} was not known", short_fingerprint(&fingerprint));
}
}
}
None => {
let store = TrustStore::load_or_default()?;
let peers = store.records().cloned().collect::<Vec<_>>();
let observed_peers = if discovery_enabled {
discover_native_peers(Duration::from_millis(750))
.await?
.records()
.cloned()
.collect::<Vec<_>>()
} else {
Vec::new()
};
if json {
print_json(&PeerListEvent {
event: "peers",
peers,
observed_peers,
});
} else if !quiet {
print_peers(&peers, &observed_peers);
}
}
},
Some(Command::Daemon(args)) => {
run_daemon(args, quiet, json, discovery_enabled).await?;
}
Some(Command::Config) => {
let config = AppConfig::load_or_default()?;
let redacted_config = config.redacted();
if json {
print_json(&ConfigEvent {
event: "config",
config: redacted_config,
});
} else {
print!("{}", redacted_config.to_toml_string()?);
}
}
Some(Command::Identity) => {
let config = AppConfig::load_or_default()?;
let identity = NativeIdentity::load_or_generate()?;
if json {
print_json(&IdentityEvent {
event: "identity",
alias: &config.alias,
fingerprint: identity.fingerprint(),
short_fingerprint: short_fingerprint(identity.fingerprint()),
});
} else if !quiet {
println!("alias: {}", config.alias);
println!("fingerprint: {}", identity.fingerprint());
println!("short: {}", short_fingerprint(identity.fingerprint()));
}
}
Some(Command::Version) => {
if json {
print_json(&VersionEvent {
event: "version",
version: env!("CARGO_PKG_VERSION"),
});
} else {
println!("{}", env!("CARGO_PKG_VERSION"));
}
}
Some(Command::Tui) => {
launch_tui(discovery_enabled).await?;
}
None if should_launch_tui(
true,
std::io::stdin().is_terminal(),
std::io::stdout().is_terminal(),
) =>
{
launch_tui(discovery_enabled).await?;
}
None => {
Cli::clap_command().print_help()?;
println!();
}
}
let _ = (cli.port, cli.bind, cli.verbose);
Ok(())
}
fn trust_peer_query(
store: &mut TrustStore,
query: &str,
registry: Option<&PeerRegistry>,
) -> ferry_core::Result<KnownPeerEntry> {
if let Ok(entry) = store.trust_fingerprint(query) {
return Ok(entry.clone());
}
let Some(registry) = registry else {
return Err(ferry_core::Error::InvalidInput(
"peer trust requires a full fingerprint unless discovery can resolve the query"
.to_string(),
));
};
match registry.lookup_detail(query) {
PeerLookup::Found(record) => Ok(store.trust_record(record)?.clone()),
PeerLookup::Ambiguous(records) => Err(ferry_core::Error::AmbiguousPeer(format!(
"{query} matched {} peers",
records.len()
))),
PeerLookup::Missing => Err(ferry_core::Error::PeerNotFound(query.to_string())),
}
}
async fn run_cancellable<T>(
control: TransferControl,
operation: impl Future<Output = ferry_core::Result<T>>,
) -> ferry_core::Result<T> {
tokio::select! {
result = operation => result,
signal = tokio::signal::ctrl_c() => {
signal?;
control.cancel();
Err(ferry_core::Error::TransferCancelled)
}
}
}
async fn launch_tui(discovery_enabled: bool) -> ferry_core::Result<()> {
let trust_store = TrustStore::load_or_default()?;
let trusted_fingerprints = trust_store
.records()
.filter(|entry| entry.trust_state == TrustState::Trusted)
.map(|entry| entry.fingerprint.clone())
.collect::<BTreeSet<_>>();
let peers = if discovery_enabled {
discover_native_peers(Duration::from_millis(750))
.await?
.records()
.map(|peer| peer_row(peer, &trusted_fingerprints))
.collect()
} else {
Vec::new()
};
let picker_dir = std::env::current_dir()?;
let state = ferry_tui::AppState::bootstrap(peers, picker_dir)?;
let handle = tokio::runtime::Handle::current();
let (updates_tx, updates_rx) = mpsc::channel();
ferry_tui::run_with_handler_and_tick(
state,
move |state, command| {
start_tui_command(state, command, handle.clone(), updates_tx.clone());
Ok(())
},
move |state| {
while let Ok(update) = updates_rx.try_recv() {
apply_tui_update(state, update);
}
Ok(())
},
)?;
Ok(())
}
#[derive(Debug)]
enum TuiUpdate {
TrustSaved {
fingerprint: String,
},
Transfer(TransferEvent),
Finished {
paths: Vec<PathBuf>,
result: std::result::Result<TuiSendSummary, String>,
},
}
#[derive(Debug)]
struct TuiSendSummary {
files: usize,
bytes: u64,
}
fn start_tui_command(
state: &mut ferry_tui::AppState,
command: ferry_tui::TuiCommand,
handle: tokio::runtime::Handle,
updates: mpsc::Sender<TuiUpdate>,
) {
match command {
ferry_tui::TuiCommand::Send {
target,
fingerprint,
paths,
trust_confirmed,
..
} => {
state.set_queue_running(&paths);
state.push_log(format!("sending {} item(s) to {target}", paths.len()));
handle.spawn(run_tui_send(
target,
fingerprint,
paths,
trust_confirmed,
updates,
));
}
}
}
async fn run_tui_send(
target: String,
fingerprint: String,
paths: Vec<PathBuf>,
trust_confirmed: bool,
updates: mpsc::Sender<TuiUpdate>,
) {
let result = async {
if trust_confirmed {
save_tui_trust(&fingerprint)?;
let _ = updates.send(TuiUpdate::TrustSaved {
fingerprint: fingerprint.clone(),
});
}
let summary = send_from_tui(&target, &fingerprint, &paths, |event| {
let _ = updates.send(TuiUpdate::Transfer(event));
})
.await?;
Ok(TuiSendSummary {
files: summary.files.len(),
bytes: summary.bytes,
})
}
.await;
let _ = updates.send(TuiUpdate::Finished {
paths,
result: result.map_err(|error: ferry_core::Error| error.to_string()),
});
}
fn apply_tui_update(state: &mut ferry_tui::AppState, update: TuiUpdate) {
match update {
TuiUpdate::TrustSaved { fingerprint } => {
state.push_log(format!(
"saved trusted peer {}",
short_fingerprint(&fingerprint)
));
}
TuiUpdate::Transfer(event) => update_tui_transfer_state(state, event),
TuiUpdate::Finished { paths, result } => match result {
Ok(summary) => {
state.finish_queue_paths(&paths);
state.push_log(format!(
"sent {} file(s), {} bytes",
summary.files, summary.bytes
));
}
Err(error) => {
state.fail_queue_paths(&paths, error.clone());
state.push_error(format!("transfer failed: {error}"));
}
},
}
}
fn save_tui_trust(fingerprint: &str) -> ferry_core::Result<()> {
let mut store = TrustStore::load_or_default()?;
store.trust_fingerprint(fingerprint.to_string())?;
store.save_default_path()
}
async fn send_from_tui(
target: &str,
fingerprint: &str,
paths: &[PathBuf],
events: impl FnMut(TransferEvent),
) -> ferry_core::Result<ferry_core::TransferSummary> {
validate_send_paths(paths)?;
let peer = DirectPeer::parse(target)?.with_expected_fingerprint(fingerprint.to_string())?;
let request = SendRequest::new(peer, paths.to_vec())?;
let identity = NativeIdentity::load_or_generate()?;
send_direct_file(&request, &identity, events).await
}
fn update_tui_transfer_state(state: &mut ferry_tui::AppState, event: TransferEvent) {
match event {
TransferEvent::SessionStarted {
files_total,
bytes_total,
..
} => {
state.push_log(format!(
"transfer started: {files_total} file(s), {bytes_total} bytes"
));
}
TransferEvent::FileStarted {
file_name,
bytes_total,
resume_offset,
..
} => {
state.update_queue_progress(&file_name, resume_offset, bytes_total);
if resume_offset > 0 {
state.push_log(format!("resuming {file_name} at {resume_offset} bytes"));
}
}
TransferEvent::Progress {
file_name,
bytes_done,
bytes_total,
..
} => {
state.update_queue_progress(&file_name, bytes_done, bytes_total);
}
TransferEvent::FileFinished {
file_name, bytes, ..
} => {
state.update_queue_progress(&file_name, bytes, bytes);
state.push_log(format!("finished {file_name}"));
}
TransferEvent::SessionFinished { .. } => {}
TransferEvent::SessionCancelled { .. } => {
state.push_log("transfer cancelled");
}
}
}
fn peer_row(peer: &PeerRecord, trusted_fingerprints: &BTreeSet<String>) -> ferry_tui::PeerRow {
ferry_tui::PeerRow {
alias: peer
.aliases
.iter()
.next()
.cloned()
.unwrap_or_else(|| "-".to_string()),
fingerprint: peer.fingerprint.clone(),
transport: peer
.transports
.iter()
.next()
.cloned()
.unwrap_or_else(|| "unknown".to_string()),
target: peer
.preferred_quic_address()
.map(|address| address.to_string()),
trusted: trusted_fingerprints.contains(&peer.fingerprint),
}
}
fn should_launch_tui(no_command: bool, stdin_is_terminal: bool, stdout_is_terminal: bool) -> bool {
no_command && stdin_is_terminal && stdout_is_terminal
}
async fn resolve_send_peer(
query: &str,
expected_fingerprint: Option<&str>,
discovery_enabled: bool,
) -> ferry_core::Result<DirectPeer> {
match DirectPeer::parse(query) {
Ok(peer) => {
if let Some(fingerprint) = expected_fingerprint {
peer.with_expected_fingerprint(fingerprint.to_string())
} else {
Ok(peer)
}
}
Err(error) if !discovery_enabled => Err(error),
Err(_) => {
let registry = discover_native_peers(Duration::from_secs(2)).await?;
match registry.lookup_detail(query) {
PeerLookup::Found(record) => record
.preferred_quic_address()
.map(DirectPeer::from_address)
.map(|peer| {
peer.with_expected_fingerprint(
expected_fingerprint
.map(str::to_string)
.unwrap_or_else(|| record.fingerprint.clone()),
)
})
.transpose()?
.ok_or_else(|| ferry_core::Error::PeerNotFound(query.to_string())),
PeerLookup::Ambiguous(records) => Err(ferry_core::Error::AmbiguousPeer(format!(
"{query} matched {} peers",
records.len()
))),
PeerLookup::Missing => Err(ferry_core::Error::PeerNotFound(query.to_string())),
}
}
}
}
async fn run_daemon(
args: DaemonArgs,
quiet: bool,
json: bool,
discovery_enabled: bool,
) -> ferry_core::Result<()> {
let mut config = DaemonConfig::load_or_default()?;
if let Some(listen) = args.listen {
config.listen = listen;
}
if let Some(destination) = args.dest {
config.destination = destination;
}
config.save_default_path()?;
let identity = NativeIdentity::load_or_generate()?;
let _announcer = if discovery_enabled {
let app_config = AppConfig::load_or_default()?;
let mut announcement = NativeAnnouncement::from_config(&app_config, &identity);
announcement.quic_port = config.listen.port();
Some(NativeAnnouncer::start(&announcement)?)
} else {
None
};
if !quiet && !json {
eprintln!(
"daemon receiving on {} as {} into {}",
config.listen,
short_fingerprint(identity.fingerprint()),
config.destination.display()
);
}
loop {
let request = ReceiveRequest::new(config.listen);
let mut output = CommandOutput::new(quiet, json);
match receive_direct_file(&request, &config.destination, &identity, |event| {
output.update(event)
})
.await
{
Ok(summary) => {
output.finish();
if !quiet && !json {
print_summary("received", &summary);
}
}
Err(error) => {
output.finish();
if !quiet && !json {
eprintln!("daemon transfer failed: {error}");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
}
#[derive(Debug, Serialize)]
struct VersionEvent {
event: &'static str,
version: &'static str,
}
#[derive(Debug, Serialize)]
struct PeerListEvent {
event: &'static str,
peers: Vec<KnownPeerEntry>,
observed_peers: Vec<PeerRecord>,
}
#[derive(Debug, Serialize)]
struct PeerTrustedEvent<'a> {
event: &'static str,
fingerprint: &'a str,
}
#[derive(Debug, Serialize)]
struct PeerForgottenEvent<'a> {
event: &'static str,
fingerprint: &'a str,
removed: bool,
}
#[derive(Debug, Serialize)]
struct ConfigEvent {
event: &'static str,
config: AppConfig,
}
#[derive(Debug, Serialize)]
struct IdentityEvent<'a> {
event: &'static str,
alias: &'a str,
fingerprint: &'a str,
short_fingerprint: &'a str,
}
#[derive(Debug, Serialize)]
struct ErrorEvent<'a> {
event: &'static str,
message: &'a str,
exit_code: i32,
}
enum CommandOutput {
Human(HumanProgress),
Json,
}
impl CommandOutput {
fn new(quiet: bool, json: bool) -> Self {
if json {
Self::Json
} else {
Self::Human(HumanProgress::new(quiet))
}
}
fn update(&mut self, event: TransferEvent) {
match self {
Self::Human(progress) => progress.update(event),
Self::Json => print_json(&event),
}
}
fn finish(&mut self) {
if let Self::Human(progress) = self {
progress.finish();
}
}
}
fn print_json(value: &impl Serialize) {
println!("{}", json_line(value));
}
fn json_line(value: &impl Serialize) -> String {
serde_json::to_string(value).expect("CLI JSON events should serialize")
}
struct HumanProgress {
quiet: bool,
bar: Option<ProgressBar>,
}
impl HumanProgress {
fn new(quiet: bool) -> Self {
Self { quiet, bar: None }
}
fn update(&mut self, event: TransferEvent) {
if self.quiet {
return;
}
let Some(event) = event.progress() else {
return;
};
let bar = self.bar.get_or_insert_with(|| {
let bar = ProgressBar::new(event.bytes_total);
let style = ProgressStyle::with_template(
"{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes}",
)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("=> ");
bar.set_style(style);
bar
});
let verb = match event.direction {
TransferDirection::Send => "sending",
TransferDirection::Receive => "receiving",
};
bar.set_message(format!("{verb} {}", event.file_name));
bar.set_length(event.bytes_total);
bar.set_position(event.bytes_done);
}
fn finish(&mut self) {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
}
}
fn short_fingerprint(fingerprint: &str) -> &str {
fingerprint.get(..12).unwrap_or(fingerprint)
}
fn print_summary(verb: &str, summary: &ferry_core::TransferSummary) {
if summary.files.len() == 1 {
eprintln!(
"{verb} {} ({} bytes, blake3 {})",
summary.file_name, summary.bytes, summary.blake3
);
} else {
eprintln!(
"{verb} {} files ({} bytes, manifest blake3 {})",
summary.files.len(),
summary.bytes,
summary.blake3
);
}
}
fn print_peers(known_peers: &[KnownPeerEntry], observed_peers: &[PeerRecord]) {
if known_peers.is_empty() {
eprintln!("no known peers in trust store");
} else {
eprintln!("known peers:");
}
for peer in known_peers {
let aliases = if peer.aliases.is_empty() {
"-".to_string()
} else {
peer.aliases.iter().cloned().collect::<Vec<_>>().join(",")
};
eprintln!(
" {} {:?} aliases={}",
short_fingerprint(&peer.fingerprint),
peer.trust_state,
aliases
);
}
if observed_peers.is_empty() {
eprintln!("no peers currently observed over discovery");
return;
}
eprintln!("observed peers:");
for peer in observed_peers {
let aliases = if peer.aliases.is_empty() {
"-".to_string()
} else {
peer.aliases.iter().cloned().collect::<Vec<_>>().join(",")
};
let addresses = peer
.addresses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
eprintln!(
" {} aliases={} addresses={}",
short_fingerprint(&peer.fingerprint),
aliases,
addresses
);
}
}
fn exit_code(error: &ferry_core::Error) -> i32 {
match error {
ferry_core::Error::InvalidPeerAddress(_) => 2,
ferry_core::Error::PeerNotFound(_) | ferry_core::Error::AmbiguousPeer(_) => 2,
ferry_core::Error::InvalidInput(_)
| ferry_core::Error::IoPath { .. }
| ferry_core::Error::Io(_)
| ferry_core::Error::ConfigDirUnavailable
| ferry_core::Error::ConfigParse(_)
| ferry_core::Error::ConfigSerialize(_)
| ferry_core::Error::Discovery(_) => 5,
ferry_core::Error::Transfer(_)
| ferry_core::Error::TransferCancelled
| ferry_core::Error::PeerFingerprintMismatch { .. }
| ferry_core::Error::Connect(_)
| ferry_core::Error::Connection(_)
| ferry_core::Error::Read(_)
| ferry_core::Error::ReadExact(_)
| ferry_core::Error::Write(_)
| ferry_core::Error::ClosedStream(_)
| ferry_core::Error::NoIncomingConnection
| ferry_core::Error::CryptoConfig(_)
| ferry_core::Error::Protocol(_) => 4,
ferry_core::Error::CertificateGeneration(_)
| ferry_core::Error::Tls(_)
| ferry_core::Error::NotImplemented(_) => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clap_definition_is_valid() {
Cli::clap_command().debug_assert();
}
#[test]
fn parses_direct_send_surface() {
let cli = Cli::parse_from(["ferry", "send", "127.0.0.1:53318", "file.txt"]);
match cli.command {
Some(Command::Send(args)) => {
assert_eq!(args.peer, "127.0.0.1:53318");
assert_eq!(args.fingerprint, None);
assert_eq!(args.paths, vec![PathBuf::from("file.txt")]);
}
_ => panic!("expected send command"),
}
}
#[test]
fn parses_direct_send_with_expected_fingerprint() {
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let cli = Cli::parse_from([
"ferry",
"send",
"--fingerprint",
fingerprint,
"127.0.0.1:53318",
"file.txt",
]);
match cli.command {
Some(Command::Send(args)) => {
assert_eq!(args.peer, "127.0.0.1:53318");
assert_eq!(args.fingerprint.as_deref(), Some(fingerprint));
assert_eq!(args.paths, vec![PathBuf::from("file.txt")]);
}
_ => panic!("expected send command"),
}
}
#[test]
fn parses_direct_recv_surface() {
let cli = Cli::parse_from(["ferry", "recv", "--listen", "127.0.0.1:53318"]);
match cli.command {
Some(Command::Recv(args)) => {
assert_eq!(args.listen.unwrap().to_string(), "127.0.0.1:53318");
assert!(!args.accept_all);
assert!(args.dest.is_none());
}
_ => panic!("expected recv command"),
}
}
#[test]
fn parses_scriptable_recv_destination() {
let cli = Cli::parse_from([
"ferry",
"recv",
"--listen",
"127.0.0.1:53318",
"--dest",
"downloads",
]);
match cli.command {
Some(Command::Recv(args)) => {
assert_eq!(args.listen.unwrap().to_string(), "127.0.0.1:53318");
assert_eq!(args.dest, Some(PathBuf::from("downloads")));
}
_ => panic!("expected recv command"),
}
}
#[test]
fn parses_global_json_before_subcommand() {
let cli = Cli::parse_from(["ferry", "--json", "peers"]);
assert!(cli.json);
}
#[test]
fn parses_peer_trust_and_forget_commands() {
let trust = Cli::parse_from(["ferry", "peers", "trust", "a"]);
let forget = Cli::parse_from(["ferry", "peers", "forget", "a"]);
match trust.command {
Some(Command::Peers {
command: Some(PeersCommand::Trust { fingerprint }),
}) => assert_eq!(fingerprint, "a"),
_ => panic!("expected peer trust command"),
}
match forget.command {
Some(Command::Peers {
command: Some(PeersCommand::Forget { fingerprint }),
}) => assert_eq!(fingerprint, "a"),
_ => panic!("expected peer forget command"),
}
}
#[test]
fn parses_identity_command() {
let cli = Cli::parse_from(["ferry", "identity"]);
assert!(matches!(cli.command, Some(Command::Identity)));
}
#[test]
fn peer_trust_resolves_discovered_alias_and_stores_full_fingerprint() {
let mut store = TrustStore::default();
let mut registry = PeerRegistry::new();
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
registry
.observe(
ferry_core::PeerObservation::new(
fingerprint,
SocketAddr::from(([192, 168, 1, 42], 53318)),
100,
)
.with_alias("desk")
.with_hostname("desk.local")
.with_transport("quic"),
)
.expect("peer observed");
let entry = trust_peer_query(&mut store, "desk", Some(®istry)).expect("peer trusted");
assert_eq!(entry.fingerprint, fingerprint);
assert_eq!(entry.trust_state, TrustState::Trusted);
assert!(entry.aliases.contains("desk"));
assert!(store.peers.contains_key(fingerprint));
}
#[test]
fn peer_trust_rejects_ambiguous_discovery_hint() {
let mut store = TrustStore::default();
let mut registry = PeerRegistry::new();
registry
.observe(
ferry_core::PeerObservation::new(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
SocketAddr::from(([192, 168, 1, 42], 53318)),
100,
)
.with_alias("desk"),
)
.expect("first peer observed");
registry
.observe(
ferry_core::PeerObservation::new(
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
SocketAddr::from(([192, 168, 1, 43], 53318)),
100,
)
.with_alias("desk"),
)
.expect("second peer observed");
let error = trust_peer_query(&mut store, "desk", Some(®istry))
.expect_err("ambiguous alias should fail");
assert!(matches!(error, ferry_core::Error::AmbiguousPeer(_)));
assert!(store.peers.is_empty());
}
#[test]
fn parses_daemon_surface() {
let cli = Cli::parse_from([
"ferry",
"daemon",
"--listen",
"127.0.0.1:53318",
"--dest",
"downloads",
]);
match cli.command {
Some(Command::Daemon(args)) => {
assert_eq!(args.listen.unwrap().to_string(), "127.0.0.1:53318");
assert_eq!(args.dest, Some(PathBuf::from("downloads")));
}
_ => panic!("expected daemon command"),
}
}
#[test]
fn serializes_transfer_event_as_newline_json_payload() {
let event = TransferEvent::Progress {
direction: TransferDirection::Send,
file_name: "file.txt".to_string(),
bytes_done: 4,
bytes_total: 8,
};
let line = json_line(&event);
assert_eq!(
line,
r#"{"event":"progress","direction":"send","file_name":"file.txt","bytes_done":4,"bytes_total":8}"#
);
}
#[test]
fn serializes_version_event_for_json_mode() {
let line = json_line(&VersionEvent {
event: "version",
version: "0.1.0",
});
assert_eq!(line, r#"{"event":"version","version":"0.1.0"}"#);
}
#[test]
fn serializes_peer_trust_event_for_json_mode() {
let line = json_line(&PeerTrustedEvent {
event: "peer_trusted",
fingerprint: "0123456789abcdef",
});
assert_eq!(
line,
r#"{"event":"peer_trusted","fingerprint":"0123456789abcdef"}"#
);
}
#[test]
fn serializes_config_event_for_json_mode() {
let line = json_line(&ConfigEvent {
event: "config",
config: AppConfig {
alias: "desk".to_string(),
..AppConfig::default()
},
});
assert!(line.starts_with(r#"{"event":"config","config":{"alias":"desk""#));
}
#[test]
fn serializes_config_event_without_secret_psk() {
let line = json_line(&ConfigEvent {
event: "config",
config: AppConfig {
trust: ferry_core::TrustConfig {
psk: "do-not-print-me".to_string(),
..ferry_core::TrustConfig::default()
},
..AppConfig::default()
}
.redacted(),
});
assert!(!line.contains("do-not-print-me"));
assert!(line.contains(r#""psk":"<redacted>""#));
}
#[test]
fn serializes_identity_event_for_json_mode() {
let line = json_line(&IdentityEvent {
event: "identity",
alias: "desk",
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
short_fingerprint: "0123456789ab",
});
assert_eq!(
line,
r#"{"event":"identity","alias":"desk","fingerprint":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","short_fingerprint":"0123456789ab"}"#
);
}
#[test]
fn serializes_error_event_for_json_mode() {
let line = json_line(&ErrorEvent {
event: "error",
message: "peer fingerprint mismatch: expected abc, got def",
exit_code: 4,
});
assert_eq!(
line,
r#"{"event":"error","message":"peer fingerprint mismatch: expected abc, got def","exit_code":4}"#
);
}
#[test]
fn maps_transfer_failure_exit_code() {
let error = ferry_core::Error::Transfer("failed mid-flight".to_string());
assert_eq!(exit_code(&error), 4);
}
#[test]
fn no_args_launches_tui_only_for_interactive_streams() {
assert!(should_launch_tui(true, true, true));
assert!(!should_launch_tui(true, true, false));
assert!(!should_launch_tui(true, false, true));
assert!(!should_launch_tui(false, true, true));
}
#[test]
fn maps_peer_parse_failure_exit_code() {
let error = DirectPeer::parse("127.0.0.1").expect_err("missing port is invalid");
assert_eq!(exit_code(&error), 2);
}
}