use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{io::BufRead, io::BufReader};
use serde_json::Value;
const FINGERPRINT: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn version_json_mode_emits_one_newline_delimited_event() {
let output = ferry_command()
.args(["--json", "version"])
.output()
.expect("ferry version runs");
assert!(
output.status.success(),
"ferry version failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout is utf8");
let lines = stdout.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 1);
let event: Value = serde_json::from_str(lines[0]).expect("version event is json");
assert_eq!(event["event"], "version");
assert!(event["version"].as_str().is_some());
}
#[test]
fn peers_json_mode_persists_and_lists_trusted_peer() {
let config = IsolatedConfig::new("peers-json");
let trust = ferry_command()
.envs(config.envs())
.args(["--json", "peers", "trust", FINGERPRINT])
.output()
.expect("ferry peers trust runs");
assert!(
trust.status.success(),
"ferry peers trust failed: {}",
String::from_utf8_lossy(&trust.stderr)
);
let trusted: Value = serde_json::from_slice(&trust.stdout).expect("peer trusted event is json");
assert_eq!(trusted["event"], "peer_trusted");
assert_eq!(trusted["fingerprint"], FINGERPRINT);
let list = ferry_command()
.envs(config.envs())
.args(["--json", "--no-discovery", "peers"])
.output()
.expect("ferry peers runs");
assert!(
list.status.success(),
"ferry peers failed: {}",
String::from_utf8_lossy(&list.stderr)
);
let listed: Value = serde_json::from_slice(&list.stdout).expect("peer list event is json");
assert_eq!(listed["event"], "peers");
assert_eq!(listed["peers"][0]["fingerprint"], FINGERPRINT);
assert_eq!(listed["peers"][0]["trust_state"], "trusted");
}
#[test]
fn identity_json_mode_emits_full_fingerprint_for_pinning() {
let config = IsolatedConfig::new("identity-json");
let output = ferry_command()
.envs(config.envs())
.args(["--json", "identity"])
.output()
.expect("ferry identity runs");
assert!(
output.status.success(),
"ferry identity failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let event: Value = serde_json::from_slice(&output.stdout).expect("identity event is json");
let fingerprint = event["fingerprint"]
.as_str()
.expect("fingerprint is a string");
assert_eq!(event["event"], "identity");
assert_eq!(fingerprint.len(), 64);
assert!(fingerprint.chars().all(|char| char.is_ascii_hexdigit()));
assert_eq!(event["short_fingerprint"].as_str(), fingerprint.get(..12));
}
#[test]
fn json_mode_emits_error_event_on_receiver_fingerprint_mismatch() {
let config = IsolatedConfig::new("fingerprint-mismatch-json");
let source_dir = config.path("source");
let dest_dir = config.path("dest");
std::fs::create_dir_all(&source_dir).expect("source dir created");
std::fs::create_dir_all(&dest_dir).expect("dest dir created");
let source_file = source_dir.join("hello.txt");
std::fs::write(&source_file, b"hello from ferry").expect("source file written");
let reserved_socket =
std::net::UdpSocket::bind("127.0.0.1:0").expect("reserved udp socket binds");
let recv_addr = reserved_socket.local_addr().expect("reserved addr");
drop(reserved_socket);
let mut receiver = ferry_command()
.envs(config.envs())
.args(["--no-discovery", "recv", "--listen"])
.arg(recv_addr.to_string())
.arg("--dest")
.arg(&dest_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("ferry recv starts");
let stderr = receiver.stderr.take().expect("receiver stderr is piped");
let mut stderr = BufReader::new(stderr);
let mut ready_line = String::new();
stderr
.read_line(&mut ready_line)
.expect("receiver readiness line is readable");
assert!(
ready_line.contains("receiving on"),
"unexpected receiver readiness line: {ready_line}"
);
std::thread::sleep(Duration::from_millis(150));
let output = ferry_command()
.envs(config.envs())
.args(["--json", "--no-discovery", "send", "--fingerprint"])
.arg("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
.arg(recv_addr.to_string())
.arg(&source_file)
.output()
.expect("ferry send runs");
assert!(!output.status.success(), "ferry send should fail");
assert_eq!(output.status.code(), Some(4));
let stdout = String::from_utf8(output.stdout).expect("stdout is utf8");
let lines = stdout.lines().collect::<Vec<_>>();
let event: Value = serde_json::from_str(
lines
.last()
.expect("fingerprint mismatch emits an error event"),
)
.expect("error event is json");
assert_eq!(event["event"], "error");
assert_eq!(event["exit_code"], 4);
assert!(
event["message"]
.as_str()
.is_some_and(|message| message.contains("peer fingerprint mismatch"))
);
assert!(!dest_dir.join("hello.txt").exists());
let _ = receiver.kill();
let _ = receiver.wait();
}
#[test]
fn json_mode_emits_error_event_on_failure() {
let output = ferry_command()
.args([
"--json",
"--no-discovery",
"send",
"127.0.0.1",
"missing.txt",
])
.output()
.expect("ferry send runs");
assert!(!output.status.success(), "ferry send should fail");
let stdout = String::from_utf8(output.stdout).expect("stdout is utf8");
let lines = stdout.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 1);
let event: Value = serde_json::from_str(lines[0]).expect("error event is json");
assert_eq!(event["event"], "error");
assert_eq!(event["exit_code"], 2);
assert!(
event["message"]
.as_str()
.is_some_and(|message| { message.contains("invalid peer address") })
);
}
fn ferry_command() -> Command {
Command::new(env!("CARGO_BIN_EXE_ferry"))
}
struct IsolatedConfig {
root: PathBuf,
}
impl IsolatedConfig {
fn new(name: &str) -> Self {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is after unix epoch")
.as_nanos();
let root =
std::env::temp_dir().join(format!("fileferry-{name}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(root.join("home")).expect("home dir created");
std::fs::create_dir_all(root.join("xdg-config")).expect("xdg config dir created");
std::fs::create_dir_all(root.join("appdata")).expect("appdata dir created");
Self { root }
}
fn envs(&self) -> Vec<(&'static str, PathBuf)> {
vec![
("HOME", self.root.join("home")),
("USERPROFILE", self.root.join("home")),
("XDG_CONFIG_HOME", self.root.join("xdg-config")),
("APPDATA", self.root.join("appdata")),
]
}
fn path(&self, name: &str) -> PathBuf {
self.root.join(name)
}
}
impl Drop for IsolatedConfig {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}