From 02ba59972ee4bf874b0c1f0c46b57ca0fa4684e7 Mon Sep 17 00:00:00 2001 From: Sidney Date: Sun, 3 May 2026 14:35:46 +0200 Subject: [PATCH 1/9] feat(trust): add fileless device pairing --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 24 +- crates/yoop-cli/src/commands/mod.rs | 35 ++ crates/yoop-cli/src/commands/trust.rs | 519 +++++++++++++++++++++++- crates/yoop-core/src/connection/mod.rs | 21 +- crates/yoop-core/src/crypto/identity.rs | 46 ++- crates/yoop-core/src/lib.rs | 4 + crates/yoop-core/src/pairing.rs | 453 +++++++++++++++++++++ crates/yoop-core/src/protocol/mod.rs | 131 ++++++ 10 files changed, 1212 insertions(+), 27 deletions(-) create mode 100644 crates/yoop-core/src/pairing.rs diff --git a/Cargo.lock b/Cargo.lock index d531880..88c1ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3852,7 +3852,7 @@ dependencies = [ [[package]] name = "yoop" -version = "0.1.6" +version = "0.1.8" dependencies = [ "anyhow", "arboard", @@ -3878,7 +3878,7 @@ dependencies = [ [[package]] name = "yoop-core" -version = "0.1.6" +version = "0.1.8" dependencies = [ "arboard", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index f2c4f92..f021dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.2.0" # x-release-please-version +version = "0.1.8" # x-release-please-version edition = "2021" rust-version = "1.86.0" authors = ["Yoop Contributors"] diff --git a/README.md b/README.md index ef270a8..262ba84 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,12 @@ yoop clipboard sync --device "My-Mac" # First-time pairing over VPN # Device A: -yoop clipboard sync -# → Code: A7K9 +yoop trust pair --listen # Device B: -yoop clipboard sync --host 100.103.164.32 A7K9 -# After successful connection, Device B is trusted +yoop trust pair --host 100.103.164.32 +# Or scan Tailscale peers with pairing listeners: +yoop trust pair # Subsequent connections (automatic): yoop clipboard sync --device "Device-A" @@ -94,10 +94,11 @@ yoop clipboard sync --device "Device-A" **How it works:** -1. **First connection**: Use `--host IP[:PORT]` with a share code -2. **Trusted pairing**: After connection, devices are added to trust store with stored IP -3. **Future connections**: Use `--device ` for codeless connections -4. **Auto-fallback**: If discovery fails, automatically tries stored IP addresses +1. **Pairing listener**: One device runs `yoop trust pair --listen` +2. **Device discovery**: The other runs `yoop trust pair` to scan LAN and Tailscale peers, or `--host IP[:PORT]` +3. **Trusted pairing**: Both devices exchange signed Ed25519 identities and store trusted addresses +4. **Future connections**: Use `--device ` for codeless connections +5. **Auto-fallback**: If discovery fails, automatically tries stored IP addresses **Supported on all commands:** @@ -296,6 +297,13 @@ Send files directly to trusted devices without share codes: yoop share file.txt # After accepting, you'll be prompted to trust the device +# Pair without transferring files +# Device A: +yoop trust pair --listen +# Device B: +yoop trust pair # Scan LAN + Tailscale for pairing listeners +yoop trust pair --host 192.168.1.100 + # Subsequent transfers: Direct send (no code needed) yoop send "Device-Name" file.txt diff --git a/crates/yoop-cli/src/commands/mod.rs b/crates/yoop-cli/src/commands/mod.rs index 87784c8..ac2fd5d 100644 --- a/crates/yoop-cli/src/commands/mod.rs +++ b/crates/yoop-cli/src/commands/mod.rs @@ -286,6 +286,41 @@ pub enum TrustAction { /// List trusted devices List, + /// Pair with another device without transferring files + Pair { + /// Listen for one incoming pairing request + #[arg(long)] + listen: bool, + + /// Pair directly with a host IP or IP:PORT + #[arg(long, value_name = "IP[:PORT]")] + host: Option, + + /// Duration to scan for pairing listeners + #[arg(long, default_value = "5s")] + scan: String, + + /// TCP port used for the pairing handshake + #[arg(long, default_value_t = yoop_core::DEFAULT_PAIRING_PORT)] + port: u16, + + /// TCP port to store for future trusted connections + #[arg(long, default_value_t = yoop_core::DEFAULT_TRANSFER_PORT_START)] + trust_port: u16, + + /// Trust level to store (full, ask) + #[arg(long, default_value = "full")] + level: String, + + /// Accept prompts automatically + #[arg(short = 'y', long)] + yes: bool, + + /// Output machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Remove a trusted device Remove { /// Device name or ID diff --git a/crates/yoop-cli/src/commands/trust.rs b/crates/yoop-cli/src/commands/trust.rs index cbed52c..b7b58ca 100644 --- a/crates/yoop-cli/src/commands/trust.rs +++ b/crates/yoop-cli/src/commands/trust.rs @@ -1,12 +1,32 @@ //! Trust command implementation. -use anyhow::Result; +use std::collections::HashSet; +use std::io::{self, Write}; +use std::net::{IpAddr, SocketAddr}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; + +use yoop_core::config::TrustLevel; +use yoop_core::connection::parse_host_address_with_default_port; +use yoop_core::discovery::HybridListener; +use yoop_core::pairing::{self, PairingConfig, PairingIdentity, PairingListener}; +use yoop_core::trust::{TrustStore, TrustedDevice}; use super::{TrustAction, TrustArgs}; +use crate::ui::parse_duration; + +#[derive(Debug, Clone)] +struct PairingCandidate { + device_name: String, + address: SocketAddr, + source: String, +} /// Run the trust command. pub async fn run(args: TrustArgs) -> Result<()> { - let mut trust_store = yoop_core::trust::TrustStore::load()?; + let mut trust_store = TrustStore::load()?; match args.action { TrustAction::List => { @@ -27,6 +47,32 @@ pub async fn run(args: TrustArgs) -> Result<()> { } } + TrustAction::Pair { + listen, + host, + scan, + port, + trust_port, + level, + yes, + json, + } => { + run_pair( + &mut trust_store, + PairArgs { + listen, + host, + scan, + port, + trust_port, + level, + yes, + json, + }, + ) + .await?; + } + TrustAction::Remove { device } => { let device_id = trust_store .find_by_name(&device) @@ -45,13 +91,7 @@ pub async fn run(args: TrustArgs) -> Result<()> { } TrustAction::Set { device, level } => { - let trust_level = match level.to_lowercase().as_str() { - "full" => yoop_core::config::TrustLevel::Full, - "ask" | "ask_each_time" => yoop_core::config::TrustLevel::AskEachTime, - _ => { - anyhow::bail!("Invalid trust level: {}. Use 'full' or 'ask'.", level); - } - }; + let trust_level = parse_trust_level(&level)?; let device_id = trust_store .find_by_name(&device) @@ -72,3 +112,464 @@ pub async fn run(args: TrustArgs) -> Result<()> { Ok(()) } + +struct PairArgs { + listen: bool, + host: Option, + scan: String, + port: u16, + trust_port: u16, + level: String, + yes: bool, + json: bool, +} + +async fn run_pair(trust_store: &mut TrustStore, args: PairArgs) -> Result<()> { + if args.listen && args.host.is_some() { + bail!("--listen cannot be used with --host"); + } + + let global_config = super::load_config(); + let pairing_config = PairingConfig { + pairing_port: args.port, + trust_port: args.trust_port, + discovery_port: global_config.network.port, + device_name: global_config.general.device_name, + ..PairingConfig::default() + }; + + if args.listen { + return run_pair_listener( + trust_store, + pairing_config, + &args.level, + args.yes, + args.json, + ) + .await; + } + + if let Some(host) = args.host { + let addr = parse_host_address_with_default_port(&host, args.port)?; + return pair_with_address( + trust_store, + addr, + pairing_config, + &args.level, + args.yes, + args.json, + ) + .await; + } + + run_pair_scan( + trust_store, + pairing_config, + &args.scan, + &args.level, + args.yes, + args.json, + ) + .await +} + +async fn run_pair_listener( + trust_store: &mut TrustStore, + pairing_config: PairingConfig, + level: &str, + yes: bool, + json: bool, +) -> Result<()> { + let listener = PairingListener::bind(pairing_config).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "listening", + "pairing_port": listener.pairing_port(), + }))? + ); + } else { + println!(); + println!("Yoop Trust Pairing"); + println!("{}", "-".repeat(37)); + println!(" Listening on pairing port {}.", listener.pairing_port()); + println!(" On the other device, run: yoop trust pair"); + println!(); + } + + loop { + let pending = match listener.wait_for_peer().await { + Ok(pending) => pending, + Err(yoop_core::Error::ConnectionRejected) => { + if !json { + println!(" Pairing probe/rejection received, still listening..."); + } + continue; + } + Err(e) => { + if !json { + eprintln!(" Pairing attempt failed: {}", e); + eprintln!(" Still listening..."); + } + continue; + } + }; + + let peer = pending.peer().clone(); + if !json { + display_pairing_identity("Incoming pairing request", &peer); + } + + let accepted = yes || prompt_yes_no("Trust this device?", true)?; + if !accepted { + let _ = pending + .finish(false, Some("rejected by user".to_string())) + .await; + if !json { + println!(" Pairing rejected."); + } + continue; + } + + let trust_level = choose_trust_level(level, yes)?; + let peer = pending.finish(true, None).await?; + save_trusted_peer(trust_store, &peer, trust_level)?; + output_pairing_success(&peer, json)?; + break; + } + + listener.shutdown().await; + Ok(()) +} + +async fn run_pair_scan( + trust_store: &mut TrustStore, + pairing_config: PairingConfig, + scan: &str, + level: &str, + yes: bool, + json: bool, +) -> Result<()> { + let scan_duration = parse_duration(scan) + .context("Invalid scan duration. Use formats like '5s', '10s', '30s'")?; + + if !json { + println!(); + println!("Scanning for Yoop pairing listeners ({scan})..."); + println!(); + } + + let mut candidates = discover_lan_pairing_candidates(&pairing_config, scan_duration).await?; + candidates.extend(discover_tailscale_pairing_candidates(&pairing_config).await); + dedupe_candidates(&mut candidates); + + if json { + output_candidates_json(&candidates)?; + return Ok(()); + } + + if candidates.is_empty() { + println!("No pairing listeners found."); + println!("Run `yoop trust pair --listen` on the other device, then try again."); + return Ok(()); + } + + display_candidates(&candidates); + let selected = choose_candidate(&candidates)?; + pair_with_address( + trust_store, + candidates[selected].address, + pairing_config, + level, + yes, + json, + ) + .await +} + +async fn pair_with_address( + trust_store: &mut TrustStore, + addr: SocketAddr, + pairing_config: PairingConfig, + level: &str, + yes: bool, + json: bool, +) -> Result<()> { + let pending = pairing::connect(addr, pairing_config).await?; + let peer = pending.peer().clone(); + + if !json { + display_pairing_identity("Found pairing device", &peer); + } + + let accepted = yes || prompt_yes_no("Trust this device?", true)?; + if !accepted { + pending.reject("rejected by user").await?; + if !json { + println!(" Pairing rejected."); + } + return Ok(()); + } + + let trust_level = choose_trust_level(level, yes)?; + let peer = pending.accept().await?; + save_trusted_peer(trust_store, &peer, trust_level)?; + output_pairing_success(&peer, json) +} + +async fn discover_lan_pairing_candidates( + pairing_config: &PairingConfig, + duration: Duration, +) -> Result> { + let listener = HybridListener::new(pairing_config.discovery_port).await?; + let shares = listener.scan(duration).await; + + Ok(shares + .into_iter() + .filter(|share| { + share + .packet + .supports + .iter() + .any(|support| support.eq_ignore_ascii_case("pairing")) + }) + .map(|share| PairingCandidate { + device_name: share.packet.device_name, + address: SocketAddr::new(share.source.ip(), share.packet.transfer_port), + source: "lan".to_string(), + }) + .collect()) +} + +async fn discover_tailscale_pairing_candidates( + pairing_config: &PairingConfig, +) -> Vec { + let Ok(output) = Command::new("tailscale") + .args(["status", "--json"]) + .output() + else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + let Ok(status) = serde_json::from_slice::(&output.stdout) else { + return Vec::new(); + }; + + let Some(peers) = status.get("Peer").and_then(serde_json::Value::as_object) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + for peer in peers.values() { + if matches!( + peer.get("Online").and_then(serde_json::Value::as_bool), + Some(false) + ) { + continue; + } + + let display_name = tailscale_peer_name(peer); + let Some(ips) = peer + .get("TailscaleIPs") + .and_then(serde_json::Value::as_array) + else { + continue; + }; + + for ip_value in ips { + let Some(ip_str) = ip_value.as_str() else { + continue; + }; + let Ok(ip) = ip_str.parse::() else { + continue; + }; + let addr = SocketAddr::new(ip, pairing_config.pairing_port); + + if let Ok(identity) = + pairing::probe(addr, pairing_config.clone(), Duration::from_millis(900)).await + { + candidates.push(PairingCandidate { + device_name: identity.device_name, + address: addr, + source: "tailscale".to_string(), + }); + } else if let Some(name) = display_name.as_ref() { + tracing::debug!("No Yoop pairing listener on Tailscale peer {}", name); + } + } + } + + candidates +} + +fn tailscale_peer_name(peer: &serde_json::Value) -> Option { + peer.get("HostName") + .and_then(serde_json::Value::as_str) + .or_else(|| peer.get("DNSName").and_then(serde_json::Value::as_str)) + .map(|name| name.trim_end_matches('.').to_string()) +} + +fn dedupe_candidates(candidates: &mut Vec) { + let mut seen = HashSet::new(); + candidates.retain(|candidate| seen.insert(candidate.address)); + candidates.sort_by(|a, b| { + a.device_name + .to_lowercase() + .cmp(&b.device_name.to_lowercase()) + .then_with(|| a.address.cmp(&b.address)) + }); +} + +fn display_candidates(candidates: &[PairingCandidate]) { + println!("Discovered Yoop devices:"); + println!(); + for (index, candidate) in candidates.iter().enumerate() { + println!( + " {}. {:<24} {:<22} {}", + index + 1, + candidate.device_name, + candidate.address, + candidate.source + ); + } + println!(); +} + +fn choose_candidate(candidates: &[PairingCandidate]) -> Result { + if candidates.len() == 1 { + return Ok(0); + } + + loop { + print!("Trust which device? [1-{}] ", candidates.len()); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim(); + + if let Ok(index) = input.parse::() { + if (1..=candidates.len()).contains(&index) { + return Ok(index - 1); + } + } + + println!("Please enter a number between 1 and {}.", candidates.len()); + } +} + +fn display_pairing_identity(title: &str, peer: &PairingIdentity) { + println!(); + println!("{title}:"); + println!(" Name: {}", peer.device_name); + println!(" Device ID: {}", peer.device_id); + println!(" Address: {}", peer.address); + println!(); +} + +fn prompt_yes_no(question: &str, default_yes: bool) -> Result { + let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; + print!(" {question} {prompt} "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim().to_lowercase(); + + if input.is_empty() { + return Ok(default_yes); + } + + Ok(input == "y" || input == "yes") +} + +fn choose_trust_level(level: &str, yes: bool) -> Result { + if yes { + return parse_trust_level(level); + } + + println!(" Trust level:"); + println!(" (1) Full - auto-accept trusted connections"); + println!(" (2) Ask each time - confirm before trusted connections"); + print!(" Choose [1]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim() == "2" { + Ok(TrustLevel::AskEachTime) + } else { + Ok(TrustLevel::Full) + } +} + +fn parse_trust_level(level: &str) -> Result { + match level.to_lowercase().as_str() { + "full" => Ok(TrustLevel::Full), + "ask" | "ask_each_time" => Ok(TrustLevel::AskEachTime), + _ => bail!("Invalid trust level: {}. Use 'full' or 'ask'.", level), + } +} + +fn save_trusted_peer( + trust_store: &mut TrustStore, + peer: &PairingIdentity, + level: TrustLevel, +) -> Result<()> { + let mut device = TrustedDevice::new( + peer.device_id, + peer.device_name.clone(), + peer.public_key.clone(), + ) + .with_trust_level(level) + .with_address(peer.address.ip(), peer.address.port()); + device.transfer_count = 0; + + trust_store.add(device)?; + Ok(()) +} + +fn output_pairing_success(peer: &PairingIdentity, json: bool) -> Result<()> { + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "paired", + "device": peer.device_name, + "device_id": peer.device_id.to_string(), + "address": peer.address.to_string(), + }))? + ); + } else { + println!(); + println!(" Device trusted: {} ({})", peer.device_name, peer.address); + println!( + " You can now use: yoop clipboard sync --device \"{}\"", + peer.device_name + ); + println!(); + } + + Ok(()) +} + +fn output_candidates_json(candidates: &[PairingCandidate]) -> Result<()> { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "devices": candidates.iter().map(|candidate| serde_json::json!({ + "name": candidate.device_name, + "address": candidate.address.to_string(), + "source": candidate.source, + })).collect::>() + }))? + ); + Ok(()) +} diff --git a/crates/yoop-core/src/connection/mod.rs b/crates/yoop-core/src/connection/mod.rs index ba3889c..cac1fe1 100644 --- a/crates/yoop-core/src/connection/mod.rs +++ b/crates/yoop-core/src/connection/mod.rs @@ -35,6 +35,16 @@ use crate::transfer::DEFAULT_TRANSFER_PORT; /// /// Returns an error if the host string cannot be parsed. pub fn parse_host_address(host: &str) -> Result { + parse_host_address_with_default_port(host, DEFAULT_TRANSFER_PORT) +} + +/// Parse a host address string into a `SocketAddr`, using `default_port` when +/// the input contains only an IP address. +/// +/// # Errors +/// +/// Returns an error if the host string cannot be parsed. +pub fn parse_host_address_with_default_port(host: &str, default_port: u16) -> Result { let host = host.trim(); if let Ok(addr) = host.parse::() { @@ -48,11 +58,11 @@ pub fn parse_host_address(host: &str) -> Result { "Invalid host format '{host}'. Use IP or IP:PORT (e.g., 192.168.1.100 or 192.168.1.100:52530)" )) })?; - return Ok(SocketAddr::new(ip, DEFAULT_TRANSFER_PORT)); + return Ok(SocketAddr::new(ip, default_port)); } if let Ok(ip) = host.parse::() { - return Ok(SocketAddr::new(ip, DEFAULT_TRANSFER_PORT)); + return Ok(SocketAddr::new(ip, default_port)); } if let Some((ip_part, port_part)) = host.rsplit_once(':') { @@ -141,4 +151,11 @@ mod tests { let addr = parse_host_address(" 192.168.1.100 ").unwrap(); assert_eq!(addr.ip().to_string(), "192.168.1.100"); } + + #[test] + fn test_parse_host_custom_default_port() { + let addr = parse_host_address_with_default_port("192.168.1.100", 52541).unwrap(); + assert_eq!(addr.ip().to_string(), "192.168.1.100"); + assert_eq!(addr.port(), 52541); + } } diff --git a/crates/yoop-core/src/crypto/identity.rs b/crates/yoop-core/src/crypto/identity.rs index cd7d2cb..59b11cc 100644 --- a/crates/yoop-core/src/crypto/identity.rs +++ b/crates/yoop-core/src/crypto/identity.rs @@ -289,16 +289,17 @@ impl DeviceIdentity { self.device_id } - /// Derive a stable device ID from a public key. + /// Derive a stable device ID from raw public key bytes. /// - /// Uses SHA-256 of the public key bytes, then takes the first 16 bytes - /// to form a UUID v4 (with version/variant bits set). - fn derive_device_id(verifying_key: &VerifyingKey) -> Uuid { + /// This is useful when validating identity metadata received before a + /// device has been trusted. + #[must_use] + pub fn derive_device_id_from_public_key(public_key_bytes: &[u8; 32]) -> Uuid { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(b"yoop:device_id:"); - hasher.update(verifying_key.as_bytes()); + hasher.update(public_key_bytes); let hash = hasher.finalize(); let mut bytes = [0u8; 16]; @@ -309,6 +310,32 @@ impl DeviceIdentity { Uuid::from_bytes(bytes) } + + /// Derive a stable device ID from a base64-encoded public key. + /// + /// # Errors + /// + /// Returns an error if the public key is not valid base64 or does not + /// decode to 32 bytes. + pub fn derive_device_id_from_public_key_base64(public_key_base64: &str) -> Result { + let public_key_bytes = BASE64_STANDARD + .decode(public_key_base64) + .map_err(|e| Error::ConfigError(format!("Failed to decode public key: {e}")))?; + + let public_key_array: [u8; 32] = public_key_bytes + .try_into() + .map_err(|_| Error::ConfigError("Invalid public key length".to_string()))?; + + Ok(Self::derive_device_id_from_public_key(&public_key_array)) + } + + /// Derive a stable device ID from a public key. + /// + /// Uses SHA-256 of the public key bytes, then takes the first 16 bytes + /// to form a UUID v4 (with version/variant bits set). + fn derive_device_id(verifying_key: &VerifyingKey) -> Uuid { + Self::derive_device_id_from_public_key(verifying_key.as_bytes()) + } } #[cfg(test)] @@ -364,6 +391,15 @@ mod tests { let derived = DeviceIdentity::derive_device_id(&identity.verifying_key()); assert_eq!(derived, identity.device_id()); + + let derived_from_public_key = + DeviceIdentity::derive_device_id_from_public_key(&identity.public_key_bytes()); + assert_eq!(derived_from_public_key, identity.device_id()); + + let derived_from_base64 = + DeviceIdentity::derive_device_id_from_public_key_base64(&identity.public_key_base64()) + .expect("should derive from base64 public key"); + assert_eq!(derived_from_base64, identity.device_id()); } #[test] diff --git a/crates/yoop-core/src/lib.rs b/crates/yoop-core/src/lib.rs index 87f8a04..6a76178 100644 --- a/crates/yoop-core/src/lib.rs +++ b/crates/yoop-core/src/lib.rs @@ -65,6 +65,7 @@ pub mod discovery; pub mod error; pub mod file; pub mod history; +pub mod pairing; pub mod preview; pub mod protocol; pub mod qr; @@ -101,6 +102,9 @@ pub const DEFAULT_TRANSFER_PORT_START: u16 = 52530; /// Default transfer port range end pub const DEFAULT_TRANSFER_PORT_END: u16 = 52540; +/// Default trust pairing port +pub const DEFAULT_PAIRING_PORT: u16 = 52541; + /// Default code expiration time in seconds pub const DEFAULT_CODE_EXPIRATION_SECS: u64 = 300; diff --git a/crates/yoop-core/src/pairing.rs b/crates/yoop-core/src/pairing.rs new file mode 100644 index 0000000..5b4f83c --- /dev/null +++ b/crates/yoop-core/src/pairing.rs @@ -0,0 +1,453 @@ +//! Device pairing without requiring a file transfer. +//! +//! Pairing exposes only Yoop device identity metadata over a single-use TLS +//! connection. Both sides prove possession of their Ed25519 private keys before +//! the caller stores the peer in the trust database. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use base64::prelude::*; +use rand::RngCore; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; +use tokio_rustls::client::TlsStream as ClientTlsStream; +use tokio_rustls::server::TlsStream as ServerTlsStream; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use uuid::Uuid; + +use crate::crypto::{DeviceIdentity, TlsConfig}; +use crate::discovery::{DiscoveryPacket, HybridBroadcaster}; +use crate::error::{Error, Result}; +use crate::protocol::{ + self, MessageType, PairingAckPayload, PairingHelloPayload, PairingResultPayload, +}; +use crate::{DEFAULT_DISCOVERY_PORT, DEFAULT_PAIRING_PORT, DEFAULT_TRANSFER_PORT_START}; + +/// Pairing runtime configuration. +#[derive(Debug, Clone)] +pub struct PairingConfig { + /// TCP port used only for pairing identity exchange. + pub pairing_port: u16, + /// TCP port that should be stored for future trusted connections. + pub trust_port: u16, + /// UDP discovery port used to announce pairing availability. + pub discovery_port: u16, + /// How often the pairing listener broadcasts its availability. + pub broadcast_interval: Duration, + /// Local device display name. + pub device_name: String, +} + +impl Default for PairingConfig { + fn default() -> Self { + Self { + pairing_port: DEFAULT_PAIRING_PORT, + trust_port: DEFAULT_TRANSFER_PORT_START, + discovery_port: DEFAULT_DISCOVERY_PORT, + broadcast_interval: Duration::from_secs(2), + device_name: hostname::get().map_or_else( + |_| "Yoop Device".to_string(), + |h| h.to_string_lossy().to_string(), + ), + } + } +} + +/// Identity metadata exchanged during pairing. +#[derive(Debug, Clone)] +pub struct PairingIdentity { + /// Peer display name. + pub device_name: String, + /// Peer stable device ID. + pub device_id: Uuid, + /// Peer base64-encoded Ed25519 public key. + pub public_key: String, + /// Address to store for future trusted connections. + pub address: SocketAddr, +} + +/// Pairing listener that advertises and accepts identity exchanges. +pub struct PairingListener { + listener: TcpListener, + broadcaster: HybridBroadcaster, + identity: DeviceIdentity, + config: PairingConfig, +} + +impl PairingListener { + /// Bind a pairing listener and start advertising it via discovery. + /// + /// # Errors + /// + /// Returns an error if the identity, TCP listener, TLS config, or discovery + /// broadcaster cannot be initialized. + pub async fn bind(config: PairingConfig) -> Result { + validate_trust_port(config.trust_port)?; + + let identity = DeviceIdentity::load_or_generate()?; + let listener = TcpListener::bind(("0.0.0.0", config.pairing_port)).await?; + let local_port = listener.local_addr()?.port(); + + let broadcaster = HybridBroadcaster::new(config.discovery_port).await?; + let packet = DiscoveryPacket { + protocol: "yoop".to_string(), + version: "1.0".to_string(), + code: "PAIR".to_string(), + device_name: config.device_name.clone(), + device_id: identity.device_id(), + expires_at: 0, + transfer_port: local_port, + supports: vec!["tcp".to_string(), "pairing".to_string()], + file_count: 0, + total_size: 0, + preview_available: false, + }; + broadcaster.start(packet, config.broadcast_interval).await?; + + Ok(Self { + listener, + broadcaster, + identity, + config: PairingConfig { + pairing_port: local_port, + ..config + }, + }) + } + + /// Get the port this listener is bound to. + #[must_use] + pub const fn pairing_port(&self) -> u16 { + self.config.pairing_port + } + + /// Wait for one peer to request pairing. + /// + /// The returned pending pairing must be explicitly accepted or rejected by + /// calling [`PendingHostPairing::finish`]. + /// + /// # Errors + /// + /// Returns an error if the connection, TLS handshake, protocol exchange, or + /// peer identity verification fails. + pub async fn wait_for_peer(&self) -> Result { + let (stream, peer_addr) = self.listener.accept().await?; + + let acceptor = TlsAcceptor::from(Arc::new( + TlsConfig::server()? + .server_config() + .ok_or_else(|| Error::TlsError("no server config".to_string()))? + .clone(), + )); + + let mut tls_stream = acceptor + .accept(stream) + .await + .map_err(|e| Error::TlsError(format!("TLS handshake failed: {e}")))?; + + let nonce = send_pairing_hello( + &mut tls_stream, + &self.identity, + &self.config.device_name, + self.config.trust_port, + ) + .await?; + + let (header, payload) = protocol::read_frame(&mut tls_stream).await?; + if header.message_type != MessageType::PairingAck { + return Err(Error::UnexpectedMessage { + expected: "PairingAck".to_string(), + actual: format!("{:?}", header.message_type), + }); + } + + let ack: PairingAckPayload = protocol::decode_payload(&payload)?; + if !ack.accepted { + return Err(Error::ConnectionRejected); + } + + let device_name = ack + .device_name + .ok_or_else(|| Error::ProtocolError("Missing device_name in PairingAck".to_string()))?; + let device_id = ack + .device_id + .ok_or_else(|| Error::ProtocolError("Missing device_id in PairingAck".to_string()))?; + let public_key = ack + .public_key + .ok_or_else(|| Error::ProtocolError("Missing public_key in PairingAck".to_string()))?; + let signature = ack.nonce_signature.ok_or_else(|| { + Error::ProtocolError("Missing nonce_signature in PairingAck".to_string()) + })?; + let trust_port = ack + .trust_port + .ok_or_else(|| Error::ProtocolError("Missing trust_port in PairingAck".to_string()))?; + + validate_identity(device_id, &public_key, &nonce, &signature)?; + + Ok(PendingHostPairing { + peer: PairingIdentity { + device_name, + device_id, + public_key, + address: SocketAddr::new(peer_addr.ip(), trust_port), + }, + stream: tls_stream, + }) + } + + /// Stop discovery announcements and release mDNS resources. + pub async fn shutdown(self) { + self.broadcaster.stop().await; + let _ = self.broadcaster.shutdown(); + } +} + +/// A host-side pairing waiting for local approval. +pub struct PendingHostPairing { + peer: PairingIdentity, + stream: ServerTlsStream, +} + +impl PendingHostPairing { + /// Get the verified peer identity. + #[must_use] + pub const fn peer(&self) -> &PairingIdentity { + &self.peer + } + + /// Finish the pairing by accepting or rejecting the peer. + /// + /// # Errors + /// + /// Returns an error if the final result frame cannot be written. + pub async fn finish( + mut self, + accepted: bool, + error: Option, + ) -> Result { + let result = PairingResultPayload { accepted, error }; + let payload = protocol::encode_payload(&result)?; + protocol::write_frame(&mut self.stream, MessageType::PairingResult, &payload).await?; + + if accepted { + Ok(self.peer) + } else { + Err(Error::ConnectionRejected) + } + } +} + +/// A client-side pairing waiting for local approval. +pub struct PendingClientPairing { + peer: PairingIdentity, + remote_nonce: Vec, + stream: ClientTlsStream, + identity: DeviceIdentity, + config: PairingConfig, +} + +impl PendingClientPairing { + /// Get the verified peer identity. + #[must_use] + pub const fn peer(&self) -> &PairingIdentity { + &self.peer + } + + /// Accept pairing with the peer and wait for the peer to accept us too. + /// + /// # Errors + /// + /// Returns an error if either side rejects or if the protocol exchange + /// fails. + pub async fn accept(mut self) -> Result { + let signature = self.identity.sign(&self.remote_nonce); + let ack = PairingAckPayload { + accepted: true, + device_name: Some(self.config.device_name.clone()), + device_id: Some(self.identity.device_id()), + public_key: Some(self.identity.public_key_base64()), + nonce_signature: Some(BASE64_STANDARD.encode(signature)), + trust_port: Some(self.config.trust_port), + error: None, + }; + let payload = protocol::encode_payload(&ack)?; + protocol::write_frame(&mut self.stream, MessageType::PairingAck, &payload).await?; + + let (header, payload) = protocol::read_frame(&mut self.stream).await?; + if header.message_type != MessageType::PairingResult { + return Err(Error::UnexpectedMessage { + expected: "PairingResult".to_string(), + actual: format!("{:?}", header.message_type), + }); + } + + let result: PairingResultPayload = protocol::decode_payload(&payload)?; + if result.accepted { + Ok(self.peer) + } else { + Err(Error::ConnectionRejected) + } + } + + /// Reject pairing with the peer. + /// + /// # Errors + /// + /// Returns an error if the rejection frame cannot be written. + pub async fn reject(mut self, reason: impl Into) -> Result<()> { + let ack = PairingAckPayload { + accepted: false, + device_name: None, + device_id: None, + public_key: None, + nonce_signature: None, + trust_port: None, + error: Some(reason.into()), + }; + let payload = protocol::encode_payload(&ack)?; + protocol::write_frame(&mut self.stream, MessageType::PairingAck, &payload).await + } +} + +/// Connect to a pairing listener and return its verified identity. +/// +/// # Errors +/// +/// Returns an error if the TCP/TLS connection or identity verification fails. +pub async fn connect(addr: SocketAddr, config: PairingConfig) -> Result { + validate_trust_port(config.trust_port)?; + + let stream = TcpStream::connect(addr).await?; + let connector = TlsConnector::from(Arc::new( + TlsConfig::client()? + .client_config() + .ok_or_else(|| Error::TlsError("no client config".to_string()))? + .clone(), + )); + + let mut tls_stream = connector + .connect("localhost".try_into().unwrap(), stream) + .await + .map_err(|e| Error::TlsError(format!("TLS handshake failed: {e}")))?; + + let (header, payload) = protocol::read_frame(&mut tls_stream).await?; + if header.message_type != MessageType::PairingHello { + return Err(Error::UnexpectedMessage { + expected: "PairingHello".to_string(), + actual: format!("{:?}", header.message_type), + }); + } + + let hello: PairingHelloPayload = protocol::decode_payload(&payload)?; + let remote_nonce = validate_identity( + hello.device_id, + &hello.public_key, + &hello.nonce, + &hello.nonce_signature, + )?; + + Ok(PendingClientPairing { + peer: PairingIdentity { + device_name: hello.device_name, + device_id: hello.device_id, + public_key: hello.public_key, + address: SocketAddr::new(addr.ip(), hello.trust_port), + }, + remote_nonce, + stream: tls_stream, + identity: DeviceIdentity::load_or_generate()?, + config, + }) +} + +/// Probe a pairing listener and reject the pairing after reading identity. +/// +/// # Errors +/// +/// Returns an error if the probe cannot connect or verify the listener +/// identity before the timeout expires. +pub async fn probe( + addr: SocketAddr, + config: PairingConfig, + probe_timeout: Duration, +) -> Result { + let pending = timeout(probe_timeout, connect(addr, config)) + .await + .map_err(|_| Error::Timeout(probe_timeout.as_secs()))??; + let peer = pending.peer().clone(); + let _ = pending.reject("probe only").await; + Ok(peer) +} + +async fn send_pairing_hello( + stream: &mut S, + identity: &DeviceIdentity, + device_name: &str, + trust_port: u16, +) -> Result +where + S: tokio::io::AsyncWriteExt + Unpin, +{ + let mut nonce = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let signature = identity.sign(&nonce); + let nonce_b64 = BASE64_STANDARD.encode(nonce); + + let hello = PairingHelloPayload { + device_name: device_name.to_string(), + protocol_version: "1.0".to_string(), + device_id: identity.device_id(), + public_key: identity.public_key_base64(), + nonce: nonce_b64.clone(), + nonce_signature: BASE64_STANDARD.encode(signature), + trust_port, + }; + let payload = protocol::encode_payload(&hello)?; + protocol::write_frame(stream, MessageType::PairingHello, &payload).await?; + + Ok(nonce_b64) +} + +fn validate_identity( + device_id: Uuid, + public_key: &str, + nonce_b64: &str, + signature_b64: &str, +) -> Result> { + let derived_device_id = DeviceIdentity::derive_device_id_from_public_key_base64(public_key)?; + if derived_device_id != device_id { + return Err(Error::TrustError(format!( + "Device ID mismatch: expected derived ID {}, got {}", + derived_device_id, device_id + ))); + } + + let nonce = BASE64_STANDARD + .decode(nonce_b64) + .map_err(|e| Error::ProtocolError(format!("Invalid nonce: {e}")))?; + let signature_bytes = BASE64_STANDARD + .decode(signature_b64) + .map_err(|e| Error::ProtocolError(format!("Invalid signature: {e}")))?; + let signature: [u8; 64] = signature_bytes + .try_into() + .map_err(|_| Error::ProtocolError("Invalid signature length".to_string()))?; + + if !DeviceIdentity::verify_base64(public_key, &nonce, &signature) { + return Err(Error::TrustError("Invalid pairing signature".to_string())); + } + + Ok(nonce) +} + +fn validate_trust_port(port: u16) -> Result<()> { + if port != 0 { + return Ok(()); + } + + Err(Error::InvalidConfig { + key: "trust_port".to_string(), + reason: "must be a non-zero TCP port".to_string(), + }) +} diff --git a/crates/yoop-core/src/protocol/mod.rs b/crates/yoop-core/src/protocol/mod.rs index e3747ce..fd6bd92 100644 --- a/crates/yoop-core/src/protocol/mod.rs +++ b/crates/yoop-core/src/protocol/mod.rs @@ -113,6 +113,12 @@ pub enum MessageType { SyncComplete = 0x78, /// Sync: Status update SyncStatus = 0x79, + /// Pairing identity hello + PairingHello = 0x80, + /// Pairing identity acknowledgment + PairingAck = 0x81, + /// Pairing final result + PairingResult = 0x82, /// Error message Error = 0xFF, } @@ -157,6 +163,9 @@ impl MessageType { 0x77 => Some(Self::SyncChunkAck), 0x78 => Some(Self::SyncComplete), 0x79 => Some(Self::SyncStatus), + 0x80 => Some(Self::PairingHello), + 0x81 => Some(Self::PairingAck), + 0x82 => Some(Self::PairingResult), 0xFF => Some(Self::Error), _ => None, } @@ -478,6 +487,68 @@ pub struct TrustedVerifyAckPayload { pub reason: Option, } +/// Pairing identity hello payload. +/// +/// Sent by a pairing listener to expose only device identity metadata. It does +/// not offer or transfer files. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingHelloPayload { + /// Device name + pub device_name: String, + /// Protocol version string + pub protocol_version: String, + /// Unique device identifier + pub device_id: uuid::Uuid, + /// Base64-encoded Ed25519 public key + pub public_key: String, + /// Random nonce for challenge (32 bytes, base64-encoded) + pub nonce: String, + /// Ed25519 signature of the nonce using sender's private key + pub nonce_signature: String, + /// Port this device expects trusted direct connections to use + pub trust_port: u16, +} + +/// Pairing acknowledgment payload. +/// +/// Sent by the joining device after it has inspected and accepted or rejected +/// the pairing listener identity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingAckPayload { + /// Whether the listener identity was accepted + pub accepted: bool, + /// Device name, if accepted + #[serde(skip_serializing_if = "Option::is_none")] + pub device_name: Option, + /// Unique device identifier, if accepted + #[serde(skip_serializing_if = "Option::is_none")] + pub device_id: Option, + /// Base64-encoded Ed25519 public key, if accepted + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + /// Ed25519 signature of the listener nonce, if accepted + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce_signature: Option, + /// Port this device expects trusted direct connections to use + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_port: Option, + /// Error message if rejected + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Pairing final result payload. +/// +/// Sent by the listener after inspecting the joining device identity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingResultPayload { + /// Whether the joining device was accepted + pub accepted: bool, + /// Error message if rejected + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// Sync: Initial sync handshake payload. /// /// Sent when establishing a sync connection to exchange basic directory info. @@ -1164,6 +1235,66 @@ mod tests { ); } + #[test] + fn test_pairing_payload_serialization() { + let device_id = uuid::Uuid::new_v4(); + let hello = PairingHelloPayload { + device_name: "Pairing Device".to_string(), + protocol_version: "1.0".to_string(), + device_id, + public_key: "public_key".to_string(), + nonce: "nonce".to_string(), + nonce_signature: "signature".to_string(), + trust_port: 52530, + }; + + let encoded = encode_payload(&hello).expect("encode hello"); + let decoded: PairingHelloPayload = decode_payload(&encoded).expect("decode hello"); + + assert_eq!(decoded.device_name, hello.device_name); + assert_eq!(decoded.device_id, device_id); + assert_eq!(decoded.trust_port, 52530); + + let ack = PairingAckPayload { + accepted: true, + device_name: Some("Peer".to_string()), + device_id: Some(device_id), + public_key: Some("peer_key".to_string()), + nonce_signature: Some("peer_signature".to_string()), + trust_port: Some(52530), + error: None, + }; + + let encoded = encode_payload(&ack).expect("encode ack"); + let decoded: PairingAckPayload = decode_payload(&encoded).expect("decode ack"); + + assert!(decoded.accepted); + assert_eq!(decoded.device_name, Some("Peer".to_string())); + assert_eq!(decoded.trust_port, Some(52530)); + + let result = PairingResultPayload { + accepted: true, + error: None, + }; + let encoded = encode_payload(&result).expect("encode result"); + let decoded: PairingResultPayload = decode_payload(&encoded).expect("decode result"); + + assert!(decoded.accepted); + } + + #[test] + fn test_pairing_message_types() { + assert_eq!( + MessageType::from_byte(0x80), + Some(MessageType::PairingHello) + ); + assert_eq!(MessageType::from_byte(0x81), Some(MessageType::PairingAck)); + assert_eq!( + MessageType::from_byte(0x82), + Some(MessageType::PairingResult) + ); + } + #[test] fn test_sync_message_types() { assert_eq!(MessageType::from_byte(0x70), Some(MessageType::SyncInit)); From 9a2024a07fadde6349422775eef43e0751129ca7 Mon Sep 17 00:00:00 2001 From: Sidney Date: Fri, 8 May 2026 10:04:02 +0200 Subject: [PATCH 2/9] fix(trust): address pairing review feedback --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/yoop-cli/src/commands/trust.rs | 72 +++++++++++++++++++++++---- crates/yoop-core/src/pairing.rs | 30 +++++++---- 4 files changed, 85 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88c1ca2..7e0f300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3852,7 +3852,7 @@ dependencies = [ [[package]] name = "yoop" -version = "0.1.8" +version = "0.2.0" dependencies = [ "anyhow", "arboard", @@ -3878,7 +3878,7 @@ dependencies = [ [[package]] name = "yoop-core" -version = "0.1.8" +version = "0.2.0" dependencies = [ "arboard", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index f021dce..f2c4f92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.8" # x-release-please-version +version = "0.2.0" # x-release-please-version edition = "2021" rust-version = "1.86.0" authors = ["Yoop Contributors"] diff --git a/crates/yoop-cli/src/commands/trust.rs b/crates/yoop-cli/src/commands/trust.rs index b7b58ca..4b5e297 100644 --- a/crates/yoop-cli/src/commands/trust.rs +++ b/crates/yoop-cli/src/commands/trust.rs @@ -265,19 +265,29 @@ async fn run_pair_scan( candidates.extend(discover_tailscale_pairing_candidates(&pairing_config).await); dedupe_candidates(&mut candidates); - if json { - output_candidates_json(&candidates)?; - return Ok(()); - } - if candidates.is_empty() { - println!("No pairing listeners found."); - println!("Run `yoop trust pair --listen` on the other device, then try again."); + if json { + output_candidates_json("no_devices", &candidates)?; + } else { + println!("No pairing listeners found."); + println!("Run `yoop trust pair --listen` on the other device, then try again."); + } return Ok(()); } - display_candidates(&candidates); - let selected = choose_candidate(&candidates)?; + let selected = if json { + match choose_json_candidate(&candidates) { + Ok(selected) => selected, + Err(error) => { + output_candidates_json("selection_required", &candidates)?; + return Err(error); + } + } + } else { + display_candidates(&candidates); + choose_candidate(&candidates)? + }; + pair_with_address( trust_store, candidates[selected].address, @@ -464,6 +474,16 @@ fn choose_candidate(candidates: &[PairingCandidate]) -> Result { } } +fn choose_json_candidate(candidates: &[PairingCandidate]) -> Result { + match candidates.len() { + 1 => Ok(0), + 0 => bail!("No pairing listeners found."), + count => bail!( + "Found {count} pairing listeners. Run without --json to choose interactively, or pass --host IP:PORT." + ), + } +} + fn display_pairing_identity(title: &str, peer: &PairingIdentity) { println!(); println!("{title}:"); @@ -560,10 +580,11 @@ fn output_pairing_success(peer: &PairingIdentity, json: bool) -> Result<()> { Ok(()) } -fn output_candidates_json(candidates: &[PairingCandidate]) -> Result<()> { +fn output_candidates_json(status: &str, candidates: &[PairingCandidate]) -> Result<()> { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ + "status": status, "devices": candidates.iter().map(|candidate| serde_json::json!({ "name": candidate.device_name, "address": candidate.address.to_string(), @@ -573,3 +594,34 @@ fn output_candidates_json(candidates: &[PairingCandidate]) -> Result<()> { ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn candidate(port: u16) -> PairingCandidate { + PairingCandidate { + device_name: format!("device-{port}"), + address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), + source: "test".to_string(), + } + } + + #[test] + fn json_scan_selects_single_candidate() { + let candidates = vec![candidate(17777)]; + + assert_eq!(choose_json_candidate(&candidates).unwrap(), 0); + } + + #[test] + fn json_scan_requires_explicit_selection_for_multiple_candidates() { + let candidates = vec![candidate(17777), candidate(17778)]; + + let error = choose_json_candidate(&candidates).unwrap_err(); + assert!(error + .to_string() + .contains("Run without --json to choose interactively")); + } +} diff --git a/crates/yoop-core/src/pairing.rs b/crates/yoop-core/src/pairing.rs index 5b4f83c..7bfe28c 100644 --- a/crates/yoop-core/src/pairing.rs +++ b/crates/yoop-core/src/pairing.rs @@ -25,6 +25,8 @@ use crate::protocol::{ }; use crate::{DEFAULT_DISCOVERY_PORT, DEFAULT_PAIRING_PORT, DEFAULT_TRANSFER_PORT_START}; +const PAIRING_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + /// Pairing runtime configuration. #[derive(Debug, Clone)] pub struct PairingConfig { @@ -142,9 +144,9 @@ impl PairingListener { .clone(), )); - let mut tls_stream = acceptor - .accept(stream) + let mut tls_stream = timeout(PAIRING_HANDSHAKE_TIMEOUT, acceptor.accept(stream)) .await + .map_err(|_| Error::Timeout(PAIRING_HANDSHAKE_TIMEOUT.as_secs()))? .map_err(|e| Error::TlsError(format!("TLS handshake failed: {e}")))?; let nonce = send_pairing_hello( @@ -155,7 +157,8 @@ impl PairingListener { ) .await?; - let (header, payload) = protocol::read_frame(&mut tls_stream).await?; + let (header, payload) = + protocol::read_frame_with_timeout(&mut tls_stream, PAIRING_HANDSHAKE_TIMEOUT).await?; if header.message_type != MessageType::PairingAck { return Err(Error::UnexpectedMessage { expected: "PairingAck".to_string(), @@ -275,7 +278,8 @@ impl PendingClientPairing { let payload = protocol::encode_payload(&ack)?; protocol::write_frame(&mut self.stream, MessageType::PairingAck, &payload).await?; - let (header, payload) = protocol::read_frame(&mut self.stream).await?; + let (header, payload) = + protocol::read_frame_with_timeout(&mut self.stream, PAIRING_HANDSHAKE_TIMEOUT).await?; if header.message_type != MessageType::PairingResult { return Err(Error::UnexpectedMessage { expected: "PairingResult".to_string(), @@ -319,7 +323,9 @@ impl PendingClientPairing { pub async fn connect(addr: SocketAddr, config: PairingConfig) -> Result { validate_trust_port(config.trust_port)?; - let stream = TcpStream::connect(addr).await?; + let stream = timeout(PAIRING_HANDSHAKE_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| Error::Timeout(PAIRING_HANDSHAKE_TIMEOUT.as_secs()))??; let connector = TlsConnector::from(Arc::new( TlsConfig::client()? .client_config() @@ -327,12 +333,16 @@ pub async fn connect(addr: SocketAddr, config: PairingConfig) -> Result Date: Mon, 11 May 2026 17:42:03 +0530 Subject: [PATCH 3/9] chore: fix current clippy lints --- crates/yoop-core/src/migration/backup.rs | 6 ++---- crates/yoop-core/src/sync/index.rs | 17 +++++++++-------- crates/yoop-core/src/transfer/mod.rs | 20 +++++++++----------- crates/yoop-core/src/transfer/resume.rs | 2 +- crates/yoop-core/src/transfer/trusted.rs | 13 ++++++------- crates/yoop-core/src/trust/mod.rs | 2 +- 6 files changed, 28 insertions(+), 32 deletions(-) diff --git a/crates/yoop-core/src/migration/backup.rs b/crates/yoop-core/src/migration/backup.rs index ec049a2..494b346 100644 --- a/crates/yoop-core/src/migration/backup.rs +++ b/crates/yoop-core/src/migration/backup.rs @@ -128,7 +128,7 @@ impl BackupManager { })?; backed_up_files.push((*file_name).to_string()); - total_size += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); + total_size += fs::metadata(&dest).map_or(0, |m| m.len()); } } @@ -221,9 +221,7 @@ impl BackupManager { return Ok(0); } - manifest - .backups - .sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + manifest.backups.sort_by_key(|backup| backup.timestamp); let to_remove = manifest.backups.len() - self.max_backups; let removed_backups: Vec<_> = manifest.backups.drain(..to_remove).collect(); diff --git a/crates/yoop-core/src/sync/index.rs b/crates/yoop-core/src/sync/index.rs index 15c71ee..8ce9fc9 100644 --- a/crates/yoop-core/src/sync/index.rs +++ b/crates/yoop-core/src/sync/index.rs @@ -194,14 +194,15 @@ impl FileIndex { content_hash: remote_entry.content_hash, }); } - Some(local_entry) if local_entry.content_changed(remote_entry) => { - if remote_entry.is_newer_than(local_entry) { - ops.push(SyncOp::Modify { - path: remote_entry.path.clone(), - size: remote_entry.size, - content_hash: remote_entry.content_hash, - }); - } + Some(local_entry) + if local_entry.content_changed(remote_entry) + && remote_entry.is_newer_than(local_entry) => + { + ops.push(SyncOp::Modify { + path: remote_entry.path.clone(), + size: remote_entry.size, + content_hash: remote_entry.content_hash, + }); } _ => {} } diff --git a/crates/yoop-core/src/transfer/mod.rs b/crates/yoop-core/src/transfer/mod.rs index 4e0d72f..a45644c 100644 --- a/crates/yoop-core/src/transfer/mod.rs +++ b/crates/yoop-core/src/transfer/mod.rs @@ -770,10 +770,9 @@ impl ShareSession { (progress.total_bytes_transferred as f64 / elapsed) as u64; } let remaining = progress.total_bytes - progress.total_bytes_transferred; - if progress.speed_bps > 0 { - progress.eta = - Some(Duration::from_secs(remaining / progress.speed_bps)); - } + progress.eta = remaining + .checked_div(progress.speed_bps) + .map(Duration::from_secs); } let _ = self.progress_tx.send(progress); } @@ -1602,10 +1601,9 @@ impl ReceiveSession { (progress.total_bytes_transferred as f64 / elapsed) as u64; } let remaining = progress.total_bytes - progress.total_bytes_transferred; - if progress.speed_bps > 0 { - progress.eta = - Some(Duration::from_secs(remaining / progress.speed_bps)); - } + progress.eta = remaining + .checked_div(progress.speed_bps) + .map(Duration::from_secs); } let _ = self.progress_tx.send(progress); } @@ -2062,9 +2060,9 @@ impl ReceiveSession { progress.speed_bps = (progress.total_bytes_transferred as f64 / elapsed) as u64; } let remaining = progress.total_bytes - progress.total_bytes_transferred; - if progress.speed_bps > 0 { - progress.eta = Some(Duration::from_secs(remaining / progress.speed_bps)); - } + progress.eta = remaining + .checked_div(progress.speed_bps) + .map(Duration::from_secs); } let _ = self.progress_tx.send(progress); Ok(()) diff --git a/crates/yoop-core/src/transfer/resume.rs b/crates/yoop-core/src/transfer/resume.rs index 2de3068..872162a 100644 --- a/crates/yoop-core/src/transfer/resume.rs +++ b/crates/yoop-core/src/transfer/resume.rs @@ -280,7 +280,7 @@ impl ResumeManager { } } - states.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + states.sort_by_key(|state| std::cmp::Reverse(state.updated_at)); Ok(states) } diff --git a/crates/yoop-core/src/transfer/trusted.rs b/crates/yoop-core/src/transfer/trusted.rs index 7310dd3..5b1f02a 100644 --- a/crates/yoop-core/src/transfer/trusted.rs +++ b/crates/yoop-core/src/transfer/trusted.rs @@ -484,10 +484,9 @@ impl TrustedSendSession { (progress.total_bytes_transferred as f64 / elapsed) as u64; } let remaining = progress.total_bytes - progress.total_bytes_transferred; - if progress.speed_bps > 0 { - progress.eta = - Some(Duration::from_secs(remaining / progress.speed_bps)); - } + progress.eta = remaining + .checked_div(progress.speed_bps) + .map(Duration::from_secs); } let _ = self.progress_tx.send(progress); } @@ -969,9 +968,9 @@ impl TrustedReceiveSession { progress.speed_bps = (progress.total_bytes_transferred as f64 / elapsed) as u64; } let remaining = progress.total_bytes - progress.total_bytes_transferred; - if progress.speed_bps > 0 { - progress.eta = Some(Duration::from_secs(remaining / progress.speed_bps)); - } + progress.eta = remaining + .checked_div(progress.speed_bps) + .map(Duration::from_secs); } let _ = self.progress_tx.send(progress); Ok(()) diff --git a/crates/yoop-core/src/trust/mod.rs b/crates/yoop-core/src/trust/mod.rs index cfb184c..4cea1c3 100644 --- a/crates/yoop-core/src/trust/mod.rs +++ b/crates/yoop-core/src/trust/mod.rs @@ -374,7 +374,7 @@ impl TrustStore { .iter() .filter(|d| d.last_known_ip.is_some() && d.last_known_port.is_some()) .collect(); - devices.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + devices.sort_by_key(|device| std::cmp::Reverse(device.last_seen)); devices } } From 37d2b3107529f7a9a0990efac7ef83d6dc75ce66 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 11 May 2026 17:57:54 +0530 Subject: [PATCH 4/9] chore: fix history clippy lint --- crates/yoop-core/src/history/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/yoop-core/src/history/mod.rs b/crates/yoop-core/src/history/mod.rs index e33cd75..785d0b8 100644 --- a/crates/yoop-core/src/history/mod.rs +++ b/crates/yoop-core/src/history/mod.rs @@ -160,9 +160,7 @@ impl TransferHistoryEntry { pub fn with_stats(mut self, bytes_transferred: u64, duration_secs: u64) -> Self { self.bytes_transferred = bytes_transferred; self.duration_secs = duration_secs; - if duration_secs > 0 { - self.speed_bps = Some(bytes_transferred / duration_secs); - } + self.speed_bps = bytes_transferred.checked_div(duration_secs); self } From 51cbf79f5e0092c64add6c0af1d8a4282dd22145 Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 11 May 2026 18:13:53 +0530 Subject: [PATCH 5/9] chore: fix CI clippy and windows mdns test --- crates/yoop-cli/src/tui/app.rs | 61 +++++++++---------- .../yoop-cli/src/tui/components/file_list.rs | 4 +- .../yoop-cli/src/tui/components/status_bar.rs | 9 ++- crates/yoop-cli/src/tui/views/devices.rs | 3 +- crates/yoop-cli/src/tui/views/share.rs | 2 +- crates/yoop-core/tests/mdns_tests.rs | 10 ++- 6 files changed, 46 insertions(+), 43 deletions(-) diff --git a/crates/yoop-cli/src/tui/app.rs b/crates/yoop-cli/src/tui/app.rs index 39f5e9e..57bdd81 100644 --- a/crates/yoop-cli/src/tui/app.rs +++ b/crates/yoop-cli/src/tui/app.rs @@ -1293,15 +1293,14 @@ impl App { Action::AddExcludePattern(pattern) => { self.state.sync.exclude_patterns.push(pattern); } - Action::RemoveExcludePattern(index) => { - if index < self.state.sync.exclude_patterns.len() { - self.state.sync.exclude_patterns.remove(index); - if self.state.sync.selected_pattern_index - >= self.state.sync.exclude_patterns.len() - { - self.state.sync.selected_pattern_index = - self.state.sync.exclude_patterns.len().saturating_sub(1); - } + Action::RemoveExcludePattern(index) + if index < self.state.sync.exclude_patterns.len() => + { + self.state.sync.exclude_patterns.remove(index); + if self.state.sync.selected_pattern_index >= self.state.sync.exclude_patterns.len() + { + self.state.sync.selected_pattern_index = + self.state.sync.exclude_patterns.len().saturating_sub(1); } } Action::StartAddExcludePattern => { @@ -1327,10 +1326,8 @@ impl App { self.state.sync.focus = super::state::SyncFocus::ExcludePatterns; } - Action::SelectDeviceIndex(index) => { - if index < self.views.devices.devices.len() { - self.state.devices.selected_index = index; - } + Action::SelectDeviceIndex(index) if index < self.views.devices.devices.len() => { + self.state.devices.selected_index = index; } Action::CycleTrustLevel => { if let Some(device) = self.views.devices.get_selected_device(&self.state) { @@ -1386,10 +1383,8 @@ impl App { self.log_info("Devices list refreshed"); } - Action::SelectHistoryIndex(index) => { - if index < self.views.history.entries.len() { - self.state.history.selected_index = index; - } + Action::SelectHistoryIndex(index) if index < self.views.history.entries.len() => { + self.state.history.selected_index = index; } Action::ViewHistoryDetails => { self.state.history.focus = super::state::HistoryFocus::Details; @@ -1463,23 +1458,25 @@ impl App { self.state.config.selected_setting = 0; } } - Action::SelectConfigSectionIndex(index) => { - if index < super::state::ConfigSection::all().len() { - self.state.config.selected_section = index; - self.state.config.selected_setting = 0; - } + Action::SelectConfigSectionIndex(index) + if index < super::state::ConfigSection::all().len() => + { + self.state.config.selected_section = index; + self.state.config.selected_setting = 0; } - Action::SelectConfigSetting(index) => { - if let Some(settings) = self.views.config.current_settings(&self.state.config) { - if index < settings.len() { - self.state.config.selected_setting = index; - } - } + Action::SelectConfigSetting(index) + if self + .views + .config + .current_settings(&self.state.config) + .is_some_and(|settings| index < settings.len()) => + { + self.state.config.selected_setting = index; } - Action::StartEditSetting => { - if self.state.config.focus == super::state::ConfigFocus::Settings { - self.views.config.start_edit(&mut self.state.config); - } + Action::StartEditSetting + if self.state.config.focus == super::state::ConfigFocus::Settings => + { + self.views.config.start_edit(&mut self.state.config); } Action::UpdateEditBuffer(s) => { self.state.config.edit_buffer = s; diff --git a/crates/yoop-cli/src/tui/components/file_list.rs b/crates/yoop-cli/src/tui/components/file_list.rs index 91482c4..7199756 100644 --- a/crates/yoop-cli/src/tui/components/file_list.rs +++ b/crates/yoop-cli/src/tui/components/file_list.rs @@ -120,7 +120,7 @@ impl FileList { let size = if is_dir { calculate_dir_size(path).unwrap_or(0) } else { - std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) + std::fs::metadata(path).map_or(0, |m| m.len()) }; let icon = if is_dir { "/" } else { "" }; @@ -168,7 +168,7 @@ fn calculate_total_size(files: &[PathBuf]) -> u64 { if path.is_dir() { calculate_dir_size(path).unwrap_or(0) } else { - std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) + std::fs::metadata(path).map_or(0, |m| m.len()) } }) .sum() diff --git a/crates/yoop-cli/src/tui/components/status_bar.rs b/crates/yoop-cli/src/tui/components/status_bar.rs index bfab0e3..12aaf31 100644 --- a/crates/yoop-cli/src/tui/components/status_bar.rs +++ b/crates/yoop-cli/src/tui/components/status_bar.rs @@ -133,10 +133,9 @@ impl StatusBar { let total: u64 = transfers.iter().map(|t| t.progress.total).sum(); let transferred: u64 = transfers.iter().map(|t| t.progress.transferred).sum(); - if total == 0 { - 0 - } else { - ((transferred * 100) / total) as u8 - } + transferred + .saturating_mul(100) + .checked_div(total) + .map_or(0, |progress| progress as u8) } } diff --git a/crates/yoop-cli/src/tui/views/devices.rs b/crates/yoop-cli/src/tui/views/devices.rs index 8a57766..7b4849d 100644 --- a/crates/yoop-cli/src/tui/views/devices.rs +++ b/crates/yoop-cli/src/tui/views/devices.rs @@ -459,8 +459,7 @@ impl DevicesView { for device in store.list() { let is_online = now .duration_since(device.last_seen) - .map(|d| d.as_secs() < 300) - .unwrap_or(false); + .is_ok_and(|d| d.as_secs() < 300); let trust_level = match device.trust_level { yoop_core::config::TrustLevel::Full => "Full".to_string(), diff --git a/crates/yoop-cli/src/tui/views/share.rs b/crates/yoop-cli/src/tui/views/share.rs index bfcf52d..23aa209 100644 --- a/crates/yoop-cli/src/tui/views/share.rs +++ b/crates/yoop-cli/src/tui/views/share.rs @@ -225,7 +225,7 @@ impl ShareView { let total_size: u64 = files .iter() - .map(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)) + .map(|p| std::fs::metadata(p).map_or(0, |m| m.len())) .sum(); let file_names: Vec = files diff --git a/crates/yoop-core/tests/mdns_tests.rs b/crates/yoop-core/tests/mdns_tests.rs index 2e3ad7d..fa5e4e9 100644 --- a/crates/yoop-core/tests/mdns_tests.rs +++ b/crates/yoop-core/tests/mdns_tests.rs @@ -201,9 +201,17 @@ async fn test_hybrid_broadcaster_lifecycle() { /// Test hybrid listener find with timeout. #[tokio::test] +#[cfg_attr(windows, ignore = "Windows CI can deny UDP socket binding")] async fn test_hybrid_listener_find_timeout() { let port = 53200 + (std::process::id() % 100) as u16; - let listener = HybridListener::new(port).await.expect("create listener"); + let listener = match HybridListener::new(port).await { + Ok(listener) => listener, + Err(yoop_core::Error::Io(e)) if e.kind() == std::io::ErrorKind::PermissionDenied => { + eprintln!("Skipping test: UDP socket binding not permitted in this environment"); + return; + } + Err(e) => panic!("create listener: {e}"), + }; let code = CodeGenerator::new().generate().expect("generate code"); let result = listener.find(&code, Duration::from_millis(200)).await; From 2cf8567c84c1969d8b2331e0cb5e66fcdd1430af Mon Sep 17 00:00:00 2001 From: Sidney Date: Mon, 11 May 2026 15:41:02 +0200 Subject: [PATCH 6/9] fix(trust): make json pairing non-interactive --- crates/yoop-cli/src/commands/mod.rs | 6 ++-- crates/yoop-cli/src/commands/trust.rs | 46 +++++++++++++++++++-------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/yoop-cli/src/commands/mod.rs b/crates/yoop-cli/src/commands/mod.rs index ac2fd5d..e89ae0d 100644 --- a/crates/yoop-cli/src/commands/mod.rs +++ b/crates/yoop-cli/src/commands/mod.rs @@ -308,9 +308,9 @@ pub enum TrustAction { #[arg(long, default_value_t = yoop_core::DEFAULT_TRANSFER_PORT_START)] trust_port: u16, - /// Trust level to store (full, ask) - #[arg(long, default_value = "full")] - level: String, + /// Trust level to store (full, ask). Defaults to full in non-interactive mode. + #[arg(long)] + level: Option, /// Accept prompts automatically #[arg(short = 'y', long)] diff --git a/crates/yoop-cli/src/commands/trust.rs b/crates/yoop-cli/src/commands/trust.rs index 4b5e297..a842912 100644 --- a/crates/yoop-cli/src/commands/trust.rs +++ b/crates/yoop-cli/src/commands/trust.rs @@ -119,7 +119,7 @@ struct PairArgs { scan: String, port: u16, trust_port: u16, - level: String, + level: Option, yes: bool, json: bool, } @@ -142,7 +142,7 @@ async fn run_pair(trust_store: &mut TrustStore, args: PairArgs) -> Result<()> { return run_pair_listener( trust_store, pairing_config, - &args.level, + args.level.as_deref(), args.yes, args.json, ) @@ -155,7 +155,7 @@ async fn run_pair(trust_store: &mut TrustStore, args: PairArgs) -> Result<()> { trust_store, addr, pairing_config, - &args.level, + args.level.as_deref(), args.yes, args.json, ) @@ -166,7 +166,7 @@ async fn run_pair(trust_store: &mut TrustStore, args: PairArgs) -> Result<()> { trust_store, pairing_config, &args.scan, - &args.level, + args.level.as_deref(), args.yes, args.json, ) @@ -176,7 +176,7 @@ async fn run_pair(trust_store: &mut TrustStore, args: PairArgs) -> Result<()> { async fn run_pair_listener( trust_store: &mut TrustStore, pairing_config: PairingConfig, - level: &str, + level: Option<&str>, yes: bool, json: bool, ) -> Result<()> { @@ -222,7 +222,7 @@ async fn run_pair_listener( display_pairing_identity("Incoming pairing request", &peer); } - let accepted = yes || prompt_yes_no("Trust this device?", true)?; + let accepted = yes || json || prompt_yes_no("Trust this device?", true)?; if !accepted { let _ = pending .finish(false, Some("rejected by user".to_string())) @@ -233,7 +233,7 @@ async fn run_pair_listener( continue; } - let trust_level = choose_trust_level(level, yes)?; + let trust_level = choose_trust_level(level, yes || json)?; let peer = pending.finish(true, None).await?; save_trusted_peer(trust_store, &peer, trust_level)?; output_pairing_success(&peer, json)?; @@ -248,7 +248,7 @@ async fn run_pair_scan( trust_store: &mut TrustStore, pairing_config: PairingConfig, scan: &str, - level: &str, + level: Option<&str>, yes: bool, json: bool, ) -> Result<()> { @@ -303,7 +303,7 @@ async fn pair_with_address( trust_store: &mut TrustStore, addr: SocketAddr, pairing_config: PairingConfig, - level: &str, + level: Option<&str>, yes: bool, json: bool, ) -> Result<()> { @@ -314,7 +314,7 @@ async fn pair_with_address( display_pairing_identity("Found pairing device", &peer); } - let accepted = yes || prompt_yes_no("Trust this device?", true)?; + let accepted = yes || json || prompt_yes_no("Trust this device?", true)?; if !accepted { pending.reject("rejected by user").await?; if !json { @@ -323,7 +323,7 @@ async fn pair_with_address( return Ok(()); } - let trust_level = choose_trust_level(level, yes)?; + let trust_level = choose_trust_level(level, yes || json)?; let peer = pending.accept().await?; save_trusted_peer(trust_store, &peer, trust_level)?; output_pairing_success(&peer, json) @@ -509,11 +509,15 @@ fn prompt_yes_no(question: &str, default_yes: bool) -> Result { Ok(input == "y" || input == "yes") } -fn choose_trust_level(level: &str, yes: bool) -> Result { - if yes { +fn choose_trust_level(level: Option<&str>, non_interactive: bool) -> Result { + if let Some(level) = level { return parse_trust_level(level); } + if non_interactive { + return Ok(TrustLevel::Full); + } + println!(" Trust level:"); println!(" (1) Full - auto-accept trusted connections"); println!(" (2) Ask each time - confirm before trusted connections"); @@ -624,4 +628,20 @@ mod tests { .to_string() .contains("Run without --json to choose interactively")); } + + #[test] + fn trust_level_honors_explicit_level_without_yes() { + assert!(matches!( + choose_trust_level(Some("ask"), false).unwrap(), + TrustLevel::AskEachTime + )); + } + + #[test] + fn trust_level_defaults_to_full_in_non_interactive_mode() { + assert!(matches!( + choose_trust_level(None, true).unwrap(), + TrustLevel::Full + )); + } } From 4eb099b8c4840decfc58b8fdf6ba629f5d338527 Mon Sep 17 00:00:00 2001 From: Sidney Date: Mon, 11 May 2026 15:41:02 +0200 Subject: [PATCH 7/9] chore: fix remaining CI lints --- crates/yoop-cli/src/tui/app.rs | 14 ++++++-------- crates/yoop-core/src/clipboard/access.rs | 15 ++++++++++++--- crates/yoop-core/src/pairing.rs | 3 +-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/yoop-cli/src/tui/app.rs b/crates/yoop-cli/src/tui/app.rs index 57bdd81..0b3f1c8 100644 --- a/crates/yoop-cli/src/tui/app.rs +++ b/crates/yoop-cli/src/tui/app.rs @@ -1078,14 +1078,12 @@ impl App { Action::AddFiles(files) => { self.state.share.selected_files.extend(files); } - Action::RemoveFile(index) => { - if index < self.state.share.selected_files.len() { - self.state.share.selected_files.remove(index); - if self.state.share.selected_index >= self.state.share.selected_files.len() - && self.state.share.selected_index > 0 - { - self.state.share.selected_index -= 1; - } + Action::RemoveFile(index) if index < self.state.share.selected_files.len() => { + self.state.share.selected_files.remove(index); + if self.state.share.selected_index >= self.state.share.selected_files.len() + && self.state.share.selected_index > 0 + { + self.state.share.selected_index -= 1; } } Action::ToggleFile(index) => { diff --git a/crates/yoop-core/src/clipboard/access.rs b/crates/yoop-core/src/clipboard/access.rs index bb5165b..24ea346 100644 --- a/crates/yoop-core/src/clipboard/access.rs +++ b/crates/yoop-core/src/clipboard/access.rs @@ -424,7 +424,10 @@ mod tests { static CLIPBOARD_LOCK: Mutex<()> = Mutex::new(()); #[test] - #[cfg_attr(any(windows, target_os = "macos"), ignore)] + #[cfg_attr( + any(windows, target_os = "macos"), + ignore = "clipboard access is unreliable in headless CI" + )] fn test_create_clipboard() { let _lock = CLIPBOARD_LOCK.lock().unwrap(); let result = create_clipboard(); @@ -436,7 +439,10 @@ mod tests { } #[test] - #[cfg_attr(any(windows, target_os = "macos"), ignore)] + #[cfg_attr( + any(windows, target_os = "macos"), + ignore = "clipboard access is unreliable in headless CI" + )] fn test_clipboard_text_roundtrip() { let _lock = CLIPBOARD_LOCK.lock().unwrap(); let clipboard = create_clipboard(); @@ -466,7 +472,10 @@ mod tests { } #[test] - #[cfg_attr(any(windows, target_os = "macos"), ignore)] + #[cfg_attr( + any(windows, target_os = "macos"), + ignore = "clipboard access is unreliable in headless CI" + )] fn test_content_hash_consistency() { let _lock = CLIPBOARD_LOCK.lock().unwrap(); let clipboard = create_clipboard(); diff --git a/crates/yoop-core/src/pairing.rs b/crates/yoop-core/src/pairing.rs index 7bfe28c..9e7c101 100644 --- a/crates/yoop-core/src/pairing.rs +++ b/crates/yoop-core/src/pairing.rs @@ -429,8 +429,7 @@ fn validate_identity( let derived_device_id = DeviceIdentity::derive_device_id_from_public_key_base64(public_key)?; if derived_device_id != device_id { return Err(Error::TrustError(format!( - "Device ID mismatch: expected derived ID {}, got {}", - derived_device_id, device_id + "Device ID mismatch: expected derived ID {derived_device_id}, got {device_id}" ))); } From 63f7a9c09e924dd6a2b6db0efbb86e79ba8a8cdc Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 25 May 2026 03:46:11 +0530 Subject: [PATCH 8/9] fix(trust): require explicit approval for json pairing --- crates/yoop-cli/src/commands/trust.rs | 38 +++++++++++++++++++++--- crates/yoop-core/src/discovery/hybrid.rs | 3 +- crates/yoop-core/src/discovery/mdns.rs | 26 +++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/yoop-cli/src/commands/trust.rs b/crates/yoop-cli/src/commands/trust.rs index a842912..375f4ab 100644 --- a/crates/yoop-cli/src/commands/trust.rs +++ b/crates/yoop-cli/src/commands/trust.rs @@ -222,7 +222,15 @@ async fn run_pair_listener( display_pairing_identity("Incoming pairing request", &peer); } - let accepted = yes || json || prompt_yes_no("Trust this device?", true)?; + if json && !yes { + let reason = "approval required; rerun with --yes to accept in JSON mode"; + let _ = pending.finish(false, Some(reason.to_string())).await; + output_pairing_approval_required(&peer)?; + listener.shutdown().await; + bail!(reason); + } + + let accepted = yes || prompt_yes_no("Trust this device?", true)?; if !accepted { let _ = pending .finish(false, Some("rejected by user".to_string())) @@ -233,7 +241,7 @@ async fn run_pair_listener( continue; } - let trust_level = choose_trust_level(level, yes || json)?; + let trust_level = choose_trust_level(level, yes)?; let peer = pending.finish(true, None).await?; save_trusted_peer(trust_store, &peer, trust_level)?; output_pairing_success(&peer, json)?; @@ -314,7 +322,15 @@ async fn pair_with_address( display_pairing_identity("Found pairing device", &peer); } - let accepted = yes || json || prompt_yes_no("Trust this device?", true)?; + if json && !yes { + pending + .reject("approval required; rerun with --yes to accept in JSON mode") + .await?; + output_pairing_approval_required(&peer)?; + bail!("approval required; rerun with --yes to accept in JSON mode"); + } + + let accepted = yes || prompt_yes_no("Trust this device?", true)?; if !accepted { pending.reject("rejected by user").await?; if !json { @@ -323,7 +339,7 @@ async fn pair_with_address( return Ok(()); } - let trust_level = choose_trust_level(level, yes || json)?; + let trust_level = choose_trust_level(level, yes)?; let peer = pending.accept().await?; save_trusted_peer(trust_store, &peer, trust_level)?; output_pairing_success(&peer, json) @@ -584,6 +600,20 @@ fn output_pairing_success(peer: &PairingIdentity, json: bool) -> Result<()> { Ok(()) } +fn output_pairing_approval_required(peer: &PairingIdentity) -> Result<()> { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "approval_required", + "device": peer.device_name, + "device_id": peer.device_id.to_string(), + "address": peer.address.to_string(), + "hint": "rerun with --yes to accept in JSON mode", + }))? + ); + Ok(()) +} + fn output_candidates_json(status: &str, candidates: &[PairingCandidate]) -> Result<()> { println!( "{}", diff --git a/crates/yoop-core/src/discovery/hybrid.rs b/crates/yoop-core/src/discovery/hybrid.rs index 4afc650..f8f8693 100644 --- a/crates/yoop-core/src/discovery/hybrid.rs +++ b/crates/yoop-core/src/discovery/hybrid.rs @@ -97,6 +97,7 @@ impl HybridBroadcaster { file_count: packet.file_count, total_size: packet.total_size, protocol_version: packet.version.clone(), + supports: packet.supports.clone(), }; if let Err(e) = mdns.register(properties).await { @@ -411,7 +412,7 @@ fn mdns_to_discovered(mdns_share: MdnsDiscoveredShare) -> DiscoveredShare { device_id: mdns_share.device_id, expires_at: 0, transfer_port: mdns_share.transfer_port, - supports: vec!["tcp".to_string()], + supports: mdns_share.supports, file_count: mdns_share.file_count, total_size: mdns_share.total_size, preview_available: true, diff --git a/crates/yoop-core/src/discovery/mdns.rs b/crates/yoop-core/src/discovery/mdns.rs index 05c9366..492c0f7 100644 --- a/crates/yoop-core/src/discovery/mdns.rs +++ b/crates/yoop-core/src/discovery/mdns.rs @@ -37,6 +37,8 @@ pub mod txt_keys { pub const TOTAL_SIZE: &str = "total_size"; /// Protocol version key pub const VERSION: &str = "version"; + /// Supported capability list key + pub const SUPPORTS: &str = "supports"; } /// Properties for mDNS service registration. @@ -56,6 +58,8 @@ pub struct MdnsProperties { pub total_size: u64, /// Protocol version pub protocol_version: String, + /// Supported capabilities + pub supports: Vec, } impl MdnsProperties { @@ -69,6 +73,7 @@ impl MdnsProperties { (txt_keys::FILE_COUNT, self.file_count.to_string()), (txt_keys::TOTAL_SIZE, self.total_size.to_string()), (txt_keys::VERSION, self.protocol_version.clone()), + (txt_keys::SUPPORTS, self.supports.join(",")), ] } } @@ -92,6 +97,8 @@ pub struct MdnsDiscoveredShare { pub total_size: u64, /// Protocol version pub protocol_version: String, + /// Supported capabilities + pub supports: Vec, } impl MdnsDiscoveredShare { @@ -112,6 +119,17 @@ impl MdnsDiscoveredShare { .and_then(|s| s.parse().ok()) .unwrap_or(0); let protocol_version = get_str(txt_keys::VERSION).unwrap_or_else(|| "1.0".to_string()); + let supports = get_str(txt_keys::SUPPORTS).map_or_else( + || vec!["tcp".to_string()], + |value| { + value + .split(',') + .map(str::trim) + .filter(|support| !support.is_empty()) + .map(ToString::to_string) + .collect() + }, + ); let addresses = info.get_addresses(); let ip = addresses.iter().find(|addr| addr.is_ipv4())?; @@ -126,6 +144,7 @@ impl MdnsDiscoveredShare { file_count, total_size, protocol_version, + supports, }) } } @@ -497,14 +516,19 @@ mod tests { file_count: 5, total_size: 1_024_000, protocol_version: "1.0".to_string(), + supports: vec!["tcp".to_string(), "pairing".to_string()], }; let txt = props.to_txt_properties(); - assert_eq!(txt.len(), 6); + assert_eq!(txt.len(), 7); let code_prop = txt.iter().find(|(k, _)| *k == txt_keys::CODE); assert!(code_prop.is_some()); assert_eq!(code_prop.unwrap().1, "TEST-123"); + + let supports_prop = txt.iter().find(|(k, _)| *k == txt_keys::SUPPORTS); + assert!(supports_prop.is_some()); + assert_eq!(supports_prop.unwrap().1, "tcp,pairing"); } #[test] From dab798660b2b62bbded0ae709224a266d48ad93b Mon Sep 17 00:00:00 2001 From: sanchxt Date: Mon, 25 May 2026 04:25:02 +0530 Subject: [PATCH 9/9] fix(ci): update trust pairing test fixtures --- crates/yoop-cli/src/tui/session/state_file.rs | 3 +-- crates/yoop-core/src/file/mod.rs | 4 ++++ crates/yoop-core/tests/mdns_tests.rs | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/yoop-cli/src/tui/session/state_file.rs b/crates/yoop-cli/src/tui/session/state_file.rs index dd47c59..2f089f9 100644 --- a/crates/yoop-cli/src/tui/session/state_file.rs +++ b/crates/yoop-cli/src/tui/session/state_file.rs @@ -281,11 +281,10 @@ fn is_process_alive(pid: u32) -> bool { std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/NH"]) .output() - .map(|output| { + .is_ok_and(|output| { let stdout = String::from_utf8_lossy(&output.stdout); stdout.contains(&pid.to_string()) }) - .unwrap_or(false) } /// Check if a process is alive (fallback for unsupported platforms). diff --git a/crates/yoop-core/src/file/mod.rs b/crates/yoop-core/src/file/mod.rs index 9864b34..2d2cb51 100644 --- a/crates/yoop-core/src/file/mod.rs +++ b/crates/yoop-core/src/file/mod.rs @@ -70,6 +70,10 @@ pub fn apply_permissions(path: &Path, permissions: Option) -> Result<()> { /// Apply Unix file permissions to a file. /// /// No-op on non-Unix platforms. +/// +/// # Errors +/// +/// This function does not currently return errors on non-Unix platforms. #[cfg(not(unix))] pub fn apply_permissions(_path: &Path, _permissions: Option) -> Result<()> { Ok(()) diff --git a/crates/yoop-core/tests/mdns_tests.rs b/crates/yoop-core/tests/mdns_tests.rs index fa5e4e9..a6ce213 100644 --- a/crates/yoop-core/tests/mdns_tests.rs +++ b/crates/yoop-core/tests/mdns_tests.rs @@ -24,11 +24,12 @@ fn test_mdns_properties_to_txt_properties() { file_count: 5, total_size: 1_024_000, protocol_version: "1.0".to_string(), + supports: vec!["tcp".to_string()], }; let txt = props.to_txt_properties(); - assert_eq!(txt.len(), 6); + assert_eq!(txt.len(), 7); let code_prop = txt.iter().find(|(k, _)| *k == "code"); assert!(code_prop.is_some()); @@ -95,6 +96,7 @@ async fn test_mdns_service_registration_lifecycle() { file_count: 1, total_size: 1024, protocol_version: "1.0".to_string(), + supports: vec!["tcp".to_string()], }; let result = broadcaster.register(props).await; @@ -278,6 +280,7 @@ async fn test_mdns_registration_discovery_roundtrip() { file_count: 3, total_size: 4096, protocol_version: "1.0".to_string(), + supports: vec!["tcp".to_string()], }; broadcaster.register(props).await.expect("register");