ferry-cli 0.1.13

The ferry command for terminal-native LAN file transfer with QUIC, discovery, resume, TUI, and JSON output.
Documentation
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);
    }
}