ffsync 0.3.0

Chunk-level file synchronization for big files and fast links
Documentation
//! TLS transport tests (D16): mutual fingerprint pinning over real TCP
//! loopback, using generated identities and the same code paths as the CLI
//! (Identity::load_or_create via an isolated state dir, pin policies,
//! client_session/server_session).

#[cfg(test)]
mod tests {
    use std::process::Command;

    /// Isolates the ffsync state dir for one test (APPDATA on Windows).
    /// Returns a guard that removes the dir on drop.
    struct StateDirGuard(std::path::PathBuf);
    impl Drop for StateDirGuard {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn isolated_state_dir(name: &str) -> StateDirGuard {
        let dir = std::env::temp_dir().join(format!("ffsync_tls_state_{name}"));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // SAFETY: tests are single-threaded per test binary here; env vars
        // are set before any state-dir read in the same test.
        unsafe {
            std::env::set_var("APPDATA", &dir);
            std::env::set_var("HOME", &dir);
        }
        StateDirGuard(dir)
    }

    fn generate_identity_in(name: &str) -> (ffsync::transport::Identity, StateDirGuard) {
        let _guard = isolated_state_dir(name);
        // Generate directly (not via load_or_create) so each peer gets its
        // own identity without touching shared state.
        let certified =
            rcgen::generate_simple_self_signed(vec!["ffsync-peer".to_string()]).unwrap();
        let id = ffsync::transport::Identity {
            cert_der: certified.cert.der().as_ref().to_vec(),
            key_der: certified.signing_key.serialize_der(),
        };
        // Re-point the state guard at the actual dir for cleanup.
        (
            id,
            StateDirGuard(std::env::temp_dir().join(format!("ffsync_tls_state_{name}"))),
        )
    }

    /// One side's result of a TLS sync case.
    type SyncResult =
        Result<ffsync::protocol::sync::SyncOutcome, ffsync::protocol::sync::SyncError>;

    /// Runs one full TLS-wrapped sync between the two given identities.
    /// `client_pin`/`server_pin` select each side's PinPolicy; returns both
    /// outcomes and the fingerprints each side saw.
    fn tls_sync_case(
        name: &str,
        client_id: ffsync::transport::Identity,
        server_id: ffsync::transport::Identity,
        client_pin: Option<Vec<u8>>,
        server_pin: Option<Vec<u8>>,
    ) -> (SyncResult, SyncResult, Vec<u8>, Vec<u8>) {
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        let server_handle = std::thread::spawn(move || {
            let (tcp, _peer) = listener.accept().unwrap();
            let policy = match server_pin {
                Some(fp) => ffsync::transport::PinPolicy::Pinned(vec![fp]),
                None => ffsync::transport::PinPolicy::FirstUse,
            };
            ffsync::transport::server_session(tcp, &server_id, &policy)
        });

        let tcp = std::net::TcpStream::connect(addr).unwrap();
        let client_policy = match client_pin {
            Some(fp) => ffsync::transport::PinPolicy::Pinned(vec![fp]),
            None => ffsync::transport::PinPolicy::FirstUse,
        };
        let server_name =
            rustls::pki_types::ServerName::try_from("ffsync-peer".to_string()).unwrap();
        let (client_tls, client_saw) =
            match ffsync::transport::client_session(tcp, &client_id, &client_policy, server_name) {
                Ok((s, fp)) => (Some(s), fp),
                Err(_) => (None, Vec::new()), // handshake refused (e.g. MITM pin mismatch)
            };

        // The server side may legitimately fail its handshake too (either
        // side rejecting aborts the connection); tolerate both.
        let (server_tls, server_saw) = match server_handle.join() {
            Ok(Ok((s, fp))) => (Some(s), fp),
            Ok(Err(_)) | Err(_) => (None, Vec::new()),
        };

        // If both handshakes succeeded, drive a tiny sync through the TLS
        // streams to prove the state machines run unchanged on top.
        let (s_out, r_out) = match (client_tls, server_tls) {
            (Some(mut c), Some(mut s)) => {
                // The "file": both sides already agree (InSync path is enough
                // to prove the transport works — HELLO/VERIFY still flow).
                let dir = std::env::temp_dir().join(format!("ffsync_tls_files_{name}"));
                let _ = std::fs::remove_dir_all(&dir);
                std::fs::create_dir_all(&dir).unwrap();
                let path = dir.join("data.bin");
                std::fs::write(&path, vec![1u8; 256 * 1024]).unwrap();

                let send_req = ffsync::protocol::sync::SendRequest {
                    path: path.clone(),
                    display_path: "data.bin".into(),
                    mtime: None,
                    force_checksum: true,
                    tree: false,
                };
                let recv_req = ffsync::protocol::sync::ReceiveRequest {
                    path: path.clone(),
                    local_source: path.clone(),
                    force_checksum: true,
                    tree: false,
                    apply_mtime: None,
                };
                let pipeline = ffsync::chunking::PipelineConfig::default();
                let pipeline_recv = ffsync::chunking::PipelineConfig::default();
                let c = std::thread::spawn(move || {
                    let mut p = |_e: ffsync::protocol::sync::Progress| {};
                    ffsync::protocol::sync::send(&mut c, &send_req, &pipeline, &mut p)
                });
                let mut p = |_e: ffsync::protocol::sync::Progress| {};
                let r = ffsync::protocol::sync::receive(&mut s, &recv_req, &pipeline_recv, &mut p);
                let s_out = c.join().unwrap();
                let _ = std::fs::remove_dir_all(&dir);
                (s_out, r)
            }
            _ => (
                Err(ffsync::protocol::sync::SyncError::Eof),
                Err(ffsync::protocol::sync::SyncError::Eof),
            ),
        };
        (s_out, r_out, client_saw, server_saw)
    }

    #[test]
    fn mutual_first_use_handshake_and_sync_succeed() {
        // Neither side pins: FirstUse accepts both; the sync must converge.
        let (client, _g1) = generate_identity_in("firstuse_c");
        let (server, _g2) = generate_identity_in("firstuse_s");
        let (s, r, client_saw, server_saw) =
            tls_sync_case("firstuse", client.clone(), server.clone(), None, None);
        assert!(s.is_ok(), "sender: {s:?}");
        assert!(r.is_ok(), "receiver: {r:?}");
        assert_eq!(client_saw.len(), 32);
        assert_eq!(server_saw.len(), 32);
        // Each side saw the OTHER's certificate (not its own).
        assert_ne!(client_saw, ffsync::transport::sha256(&client.cert_der));
        assert_eq!(
            client_saw,
            ffsync::transport::sha256(&server.cert_der),
            "client must see the server's cert"
        );
    }

    #[test]
    fn pinned_peers_sync_and_mitm_is_rejected() {
        // Case 1: correct pins on both sides → sync works.
        let (c, _g1) = generate_identity_in("pinmatch_c");
        let (s, _g2) = generate_identity_in("pinmatch_s");
        let c_fp = ffsync::transport::sha256(&c.cert_der);
        let s_fp = ffsync::transport::sha256(&s.cert_der);
        let (s_out, r_out, client_saw, server_saw) = tls_sync_case(
            "pinmatch",
            c.clone(),
            s.clone(),
            Some(s_fp.clone()),
            Some(c_fp.clone()),
        );
        assert!(s_out.is_ok(), "sender: {s_out:?}");
        assert!(r_out.is_ok(), "receiver: {r_out:?}");
        assert_eq!(client_saw, s_fp, "client must see the server's cert");
        assert_eq!(server_saw, c_fp, "server must see the client's cert");

        // Case 2: the client pins a THIRD-party identity (simulated MITM):
        // the server presents its own cert, which does not match the pin —
        // the client must refuse.
        let attacker_fp = ffsync::transport::sha256(&_g5_unused().cert_der);
        let (s_out, _r_out, _cs, _ss) = tls_sync_case("mitm", c, s, Some(attacker_fp), None);
        assert!(s_out.is_err(), "client must reject the wrong cert");
    }

    #[test]
    fn unpinned_client_is_rejected_by_pinned_receiver() {
        // Receiver pins a legit client; an unknown third party connects.
        // The RECEIVER must refuse the handshake (server-side pin check) —
        // nothing about the sync may start.
        let (legit, _g1) = generate_identity_in("unpinned_legit");
        let (server, _g2) = generate_identity_in("unpinned_server");
        let (attacker, _g3) = generate_identity_in("unpinned_attacker");

        let legit_fp = ffsync::transport::sha256(&legit.cert_der);
        let attacker_fp = ffsync::transport::sha256(&attacker.cert_der);

        // Receiver pins ONLY the legit client.
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let server_handle = std::thread::spawn(move || {
            let (tcp, _peer) = listener.accept().unwrap();
            ffsync::transport::server_session(
                tcp,
                &server,
                &ffsync::transport::PinPolicy::Pinned(vec![legit_fp]),
            )
        });

        let tcp = std::net::TcpStream::connect(addr).unwrap();
        let name = rustls::pki_types::ServerName::try_from("ffsync-peer".to_string()).unwrap();
        // The attacker's client has no opinion about the server (FirstUse).
        // The client may or may not see the handshake fail locally depending
        // on timing (the receiver's alert races its first read) — the
        // load-bearing assertion is the RECEIVER's loud refusal, plus: even
        // a "successful" attacker handshake leads nowhere (the sync below
        // never starts; the receiver dropped the connection).
        let client_res = ffsync::transport::client_session(
            tcp,
            &attacker,
            &ffsync::transport::PinPolicy::FirstUse,
            name,
        );
        let server_res = server_handle.join().unwrap();
        // The receiver must refuse the attacker, loudly and specifically.
        let server_err = server_res.expect_err("receiver must refuse the attacker");
        let msg = server_err.to_string();
        assert!(
            msg.contains("not pinned"),
            "receiver error must name the pin failure, got: {msg}"
        );
        assert!(
            msg.contains(&ffsync::transport::format_fingerprint(&attacker_fp)),
            "receiver error must show the attacker's fingerprint, got: {msg}"
        );
        // The attacker learns at most that the receiver refused; if its own
        // handshake "succeeded" it still gets no ffsync session (the
        // receiver returns before sync::receive is ever called).
        let _ = client_res;
    }

    /// Generates one extra identity for MITM tests (kept separate so the
    /// pinning identities above stay readable).
    fn _g5_unused() -> ffsync::transport::Identity {
        generate_identity_in("mitm_attacker").0
    }

    #[test]
    fn identity_round_trip_and_fingerprint_format() {
        let (id, _g) = generate_identity_in("codec");
        // Fingerprint format: 32 bytes → 32 × "XX" + 31 colons = 95 chars.
        let fp = id.fingerprint();
        assert_eq!(fp.len(), 95, "format: {fp}");
        assert!(fp.chars().all(|c| c.is_ascii_hexdigit() || c == ':'));

        // parse_fingerprint accepts its own output.
        let raw = ffsync::transport::parse_fingerprint(&fp).expect("parses own format");
        assert_eq!(raw, ffsync::transport::sha256(&id.cert_der));
        // …and rejects wrong lengths.
        assert!(ffsync::transport::parse_fingerprint("AA:BB").is_none());
    }

    #[test]
    fn pins_file_add_remove_round_trip() {
        let dir = std::env::temp_dir().join(format!("ffsync_tls_pins_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let a = vec![1u8; 32];
        let b = vec![2u8; 32];

        ffsync::transport::add_pin_in(&dir, &a).unwrap();
        ffsync::transport::add_pin_in(&dir, &b).unwrap();
        // Duplicate add is a no-op.
        ffsync::transport::add_pin_in(&dir, &a).unwrap();
        let pins = ffsync::transport::load_pins_from(&dir).unwrap();
        assert_eq!(pins.len(), 2);
        assert!(pins.iter().any(|p| p == &a));
        assert!(pins.iter().any(|p| p == &b));

        ffsync::transport::remove_pin_in(&dir, &a).unwrap();
        let pins = ffsync::transport::load_pins_from(&dir).unwrap();
        assert_eq!(pins.len(), 1);
        assert_eq!(pins[0], b);

        // Removing an absent pin is a no-op.
        ffsync::transport::remove_pin_in(&dir, &a).unwrap();
        assert_eq!(ffsync::transport::load_pins_from(&dir).unwrap().len(), 1);

        let _ = std::fs::remove_dir_all(&dir);
    }

    // Silence unused-import warning for Command (kept for future CLI-level
    // integration test).
    #[allow(dead_code)]
    fn _uses() {
        let _ = Command::new("true");
    }
}