Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
airdrop
src
sender.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
sender.rs
Blame
History
Raw
//! Sending: one receiver's `/Discover`, `/Ask` and `/Upload`, each a POST on //! a keep-alive TLS connection. //! //! The shapes are a Mac's, captured from real transfers: an iPhone answered //! OpenDrop's older request shape with no UI at all. Adapted from //! omdrop-plugin's `omdrop-send` (MIT). //! //! Order matters, and the connections are not interchangeable: //! //! 1. `/Discover` on one connection, to learn the receiver's name. sharingd //! tears that handler down about a millisecond after answering, so the TCP //! socket stays open with nothing above it ever reading it again: an Ask //! sent on it is ACKed in full and never dispatched. //! 2. `/Ask` and `/Upload` on a **fresh** connection, with a long timeout, //! because the Ask blocks on the receiver's user. use std::fs::File; use std::io::{self, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use plist::{Dictionary, Value}; use rustls::ClientConfig; use crate::archive::{CpioWriter, DvzipEncoder}; use crate::connect::{Body, Conn, Endpoint, Response}; use crate::icon::file_icon; /// What every request carries, in this order; a request's own headers replace /// a value in place (Content-Type) or follow these. pub const HEADERS: [(&str, &str); 6] = [ ("Content-Type", "application/octet-stream"), ("Connection", "keep-alive"), ("Accept", "*/*"), ("User-Agent", "AirDrop/1.0"), ("Accept-Language", "en-us"), ("Accept-Encoding", "br, gzip, deflate"), ]; /// A Mac's /Discover request carries DeviceSupportFlags (and its record /// data); OpenDrop sends an empty plist. pub const DEVICE_SUPPORT_FLAGS: i64 = 111611; /// How long the receiver's user gets to accept (the Ask's socket timeout). pub const DEFAULT_ASK_TIMEOUT: Duration = Duration::from_secs(90); /// Any single socket operation outside the Ask. A wedged AWDL path once parked /// a client in TCP backoff for 4.5 minutes; a failed send must cost seconds. pub const DEFAULT_OP_TIMEOUT: Duration = Duration::from_secs(20); /// Who we are to a receiver, and the TLS identity that says so. #[derive(Clone)] pub struct Sender { pub tls: Arc<ClientConfig>, /// The DER validation record belonging to the presented certificate. pub record: Option<Vec<u8>>, pub computer_name: String, pub computer_model: String, /// The interface MAC without colons (`SenderID`). pub service_id: String, /// Link-local addresses without a zone are scoped to this interface. pub iface: String, /// Per-operation socket timeout. pub op_timeout: Option<Duration>, /// How long `/Ask` waits for the receiver's user. pub ask_timeout: Duration, } impl Sender { pub fn new( identity: &crate::identity::Identity, computer_name: impl Into<String>, computer_model: impl Into<String>, service_id: impl Into<String>, iface: impl Into<String>, ) -> Result<Sender, crate::tls::TlsError> { Ok(Sender { tls: crate::tls::client_config(identity)?, record: identity.record().map(<[u8]>::to_vec), computer_name: computer_name.into(), computer_model: computer_model.into(), service_id: service_id.into(), iface: iface.into(), op_timeout: Some(DEFAULT_OP_TIMEOUT), ask_timeout: DEFAULT_ASK_TIMEOUT, }) } } /// A file to send: the open file, and the name it travels under. /// /// Opened once, when the request is checked, and streamed from that same /// descriptor at /Upload: a path checked now and reopened later could name a /// different file by then. #[derive(Debug)] pub struct Outgoing { pub path: PathBuf, pub name: String, /// The size when opened, as the Ask announces it. The archive writer /// refuses a file that no longer has this many bytes at /Upload. pub size: u64, pub file: File, } impl Outgoing { /// Open a file to send. Only regular files (checked on the open /// descriptor): the archive carries files, and `FileIsDirectory` is always /// false. Symlinks the user names are followed. pub fn new(path: &Path) -> io::Result<Outgoing> { use std::os::unix::fs::OpenOptionsExt; // O_NONBLOCK so naming a FIFO or a device fails here instead of // hanging in open(); it changes nothing for a regular file. let file = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NONBLOCK) .open(path)?; let meta = file.metadata()?; if !meta.is_file() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "not a regular file", )); } Ok(Outgoing { path: path.to_owned(), name: basename(path), size: meta.len(), file, }) } } fn basename(path: &Path) -> String { let s = path.to_string_lossy(); s.rsplit('/').next().unwrap_or(&s).to_owned() } /// The UTI a Mac puts in an Ask's `FileType`. pub fn uti(name: &str) -> &'static str { let ext = match name.rfind('.') { // A leading dot is not an extension. Some(i) if name .get(..i) .is_some_and(|stem| !stem.trim_start_matches('.').is_empty()) => { name.get(i..).unwrap_or("").to_ascii_lowercase() } _ => String::new(), }; match ext.as_str() { ".jpg" | ".jpeg" => "public.jpeg", ".png" => "public.png", ".heic" => "public.heic", ".mov" => "com.apple.quicktime-movie", ".mp4" => "public.mpeg-4", ".pdf" => "com.adobe.pdf", ".txt" => "public.plain-text", _ => "public.data", } } /// A fresh upper-case UUID4. pub fn transfer_id() -> String { use rand_core::RngCore; let mut b = [0u8; 16]; rand_core::OsRng.fill_bytes(&mut b); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; let h: String = b.iter().map(|x| format!("{x:02X}")).collect(); format!( "{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32] ) } fn plist_binary(dict: Dictionary) -> Vec<u8> { let mut dict = dict; dict.sort_keys(); let mut out = Vec::new(); // Writing a dictionary of plain values into memory cannot fail. let _ = Value::Dictionary(dict).to_writer_binary(&mut out); out } /// How the receiver answered an Ask. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Reply { Accepted, /// 406: the user pressed Decline. Declined, /// 401/403: a Contacts Only receiver that does not know us. Refused, /// Anything else. Failed(u16), } impl Reply { fn from_status(status: u16) -> Reply { match status { 200 => Reply::Accepted, 406 => Reply::Declined, 401 | 403 => Reply::Refused, other => Reply::Failed(other), } } } /// What the receiver told `/Discover`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Discovered { /// `ReceiverComputerName`; an iPhone omits it unless it recognises us. pub name: Option<String>, pub model: Option<String>, /// The receiver's validation record, if it offered one. pub record: Option<Vec<u8>>, } /// One receiver and the connection to it. pub struct Client { sender: Sender, endpoint: Endpoint, conn: Option<Conn>, transfer_id: Option<String>, /// Caps every operation (name lookups). deadline: Option<Instant>, } impl Client { pub fn new(sender: Sender, endpoint: Endpoint) -> Client { Client { sender, endpoint, conn: None, transfer_id: None, deadline: None, } } /// Every operation on this client ends by `deadline`. pub fn with_deadline(mut self, deadline: Instant) -> Client { self.deadline = Some(deadline); self } pub fn endpoint(&self) -> &Endpoint { &self.endpoint } /// Open a fresh connection now, replacing any other. pub fn reconnect(&mut self) -> io::Result<()> { self.conn = None; self.conn = Some(Conn::open( &self.endpoint, &self.sender.tls, self.sender.op_timeout, self.deadline, )?); Ok(()) } /// POST and read the whole answer: (status, response body). fn post( &mut self, path: &str, body: Body<'_>, extra: &[(&str, String)], ) -> io::Result<(u16, Vec<u8>)> { if self.conn.as_ref().is_none_or(Conn::is_closed) { self.reconnect()?; } let mut headers: Vec<(&str, String)> = HEADERS.iter().map(|(k, v)| (*k, (*v).to_owned())).collect(); for (k, v) in extra { match headers.iter_mut().find(|(h, _)| h.eq_ignore_ascii_case(k)) { Some(slot) => slot.1 = v.clone(), None => headers.push((k, v.clone())), } } let host = self.endpoint.host_header(); let Some(conn) = self.conn.as_mut() else { return Err(io::Error::from(io::ErrorKind::NotConnected)); }; match conn.post(path, &host, &headers, body) { Ok(Response { status, body, .. }) => { log::debug!("{path} answered {status}"); Ok((status, body)) } Err(e) => { // A failed exchange leaves the connection in an unknown state. self.conn = None; Err(e) } } } /// `/Discover`: what the receiver calls itself, if it says. pub fn discover(&mut self) -> io::Result<Discovered> { let mut body = Dictionary::new(); body.insert( "DeviceSupportFlags".into(), Value::Integer(DEVICE_SUPPORT_FLAGS.into()), ); if let Some(record) = &self.sender.record { body.insert("SenderRecordData".into(), Value::Data(record.clone())); } let req = plist_binary(body); let (status, resp) = self.post("/Discover", Body::Bytes(&req), &[])?; if status == 401 || status == 403 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "the receiver is Contacts Only and does not know us", )); } // Bounded parse: a binary plist's shared references can expand a // kilobyte into billions of values, and deep nesting overflows the stack. let value = crate::safeplist::parse(&resp).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, "the /Discover answer is not a plist", ) })?; let Value::Dictionary(dict) = value else { return Err(io::Error::new( io::ErrorKind::InvalidData, "the /Discover answer is not a dictionary", )); }; let text = |key: &str| match dict.get(key) { Some(Value::String(s)) => Some(s.clone()), Some(Value::Integer(i)) => Some(i.to_string()), _ => None, }; Ok(Discovered { name: text("ReceiverComputerName"), model: text("ReceiverModelName"), record: dict .get("ReceiverRecordData") .and_then(Value::as_data) .map(<[u8]>::to_vec), }) } /// A fresh connection for the Ask and Upload, with the long timeout. pub fn begin_transfer(&mut self) -> io::Result<()> { self.reconnect()?; if let Some(conn) = self.conn.as_mut() { conn.set_op_timeout(Some(self.sender.ask_timeout))?; } Ok(()) } fn ask_common(&self, body: &mut Dictionary, bundle: &str, tid: &str, kind: &str) { body.insert( "TransferID".into(), Value::Dictionary( [("id".to_owned(), Value::String(tid.to_owned()))] .into_iter() .collect(), ), ); body.insert( "TransferType".into(), Value::Dictionary( [(kind.to_owned(), Value::Dictionary(Dictionary::new()))] .into_iter() .collect(), ), ); body.insert( "SenderID".into(), Value::String(self.sender.service_id.clone()), ); body.insert("BundleID".into(), Value::String(bundle.into())); body.insert( "SenderComputerName".into(), Value::String(self.sender.computer_name.clone()), ); body.insert( "SenderModelName".into(), Value::String(self.sender.computer_model.clone()), ); body.insert("ConvertMediaFormats".into(), Value::Boolean(false)); if let Some(record) = &self.sender.record { body.insert("SenderRecordData".into(), Value::Data(record.clone())); } } fn post_ask(&mut self, body: Dictionary) -> io::Result<Reply> { let req = plist_binary(body); let (status, _) = self.post("/Ask", Body::Bytes(&req), &[])?; Ok(Reply::from_status(status)) } /// A files Ask, shaped like a Mac's: TransferID, TransferType, Items, /// per-file FileSize, a UTI, and an icon. pub fn ask(&mut self, files: &[Outgoing]) -> io::Result<Reply> { let entries: Vec<Value> = files .iter() .map(|f| { let mut e = Dictionary::new(); e.insert("FileName".into(), Value::String(f.name.clone())); e.insert("FileType".into(), Value::String(uti(&f.name).into())); e.insert("FileSize".into(), Value::Integer(f.size.into())); e.insert("FileBomPath".into(), Value::String(format!("./{}", f.name))); e.insert("FileIsDirectory".into(), Value::Boolean(false)); e.insert("ConvertMediaFormats".into(), Value::Boolean(false)); e.sort_keys(); Value::Dictionary(e) }) .collect(); // One TransferID for Ask and Upload: sharingd sends the same UUID in the // Ask body and the Upload's TransferID header. let tid = transfer_id(); self.transfer_id = Some(tid.clone()); let mut body = Dictionary::new(); self.ask_common(&mut body, "com.apple.finder", &tid, "files"); body.insert("Items".into(), Value::Array(Vec::new())); body.insert("Files".into(), Value::Array(entries)); if let Some(icon) = files.first().and_then(|f| file_icon(&f.file)) { body.insert("FileIcon".into(), Value::Data(icon)); } self.post_ask(body) } /// A links Ask: the Ask is the whole transfer. Shaped like an iPhone's: /// TransferType `links`, the URLs in Items, no Files, and nothing on /// /Upload. pub fn ask_links(&mut self, urls: &[String]) -> io::Result<Reply> { let tid = transfer_id(); self.transfer_id = Some(tid.clone()); let mut body = Dictionary::new(); self.ask_common(&mut body, "com.apple.Safari", &tid, "links"); body.insert( "Items".into(), Value::Array(urls.iter().map(|u| Value::String(u.clone())).collect()), ); self.post_ask(body) } /// sharingd's Upload: `Content-Type: application/x-dvzip`, `TotalBytes: <sum /// of file sizes>`, `TransferID: <the Ask's UUID>`, `Transfer-Encoding: /// chunked`; the body is a DVZip-framed cpio (odc) archive of the files, /// each stored under its bare name. OpenDrop's x-cpio + gzip is rejected by /// sharingd (406). Same TLS connection as the Ask. /// /// Streamed: a file of any size costs one 64 KiB block of memory. /// `progress` is told how many wire bytes have gone out; returning `false` /// cancels the upload (the connection is dropped and the call fails with /// `ErrorKind::Interrupted`). pub fn upload( &mut self, files: &[Outgoing], mut progress: impl FnMut(u64) -> bool, ) -> io::Result<bool> { let tid = self.transfer_id.clone().unwrap_or_else(transfer_id); let total: u64 = files.iter().map(|f| f.size).sum(); let headers = [ ("Content-Type", "application/x-dvzip".to_owned()), ("TotalBytes", total.to_string()), ("TransferID", tid), ]; let mut produce = |w: &mut dyn Write| -> io::Result<()> { let wire = Reporting { inner: w, sent: 0, report: &mut progress, }; let dvzip = DvzipEncoder::new(wire); let mut cpio = CpioWriter::new(dvzip); for f in files { let mut data = &f.file; data.seek(SeekFrom::Start(0))?; let meta = f.file.metadata()?; cpio.append_file(&f.name, &meta, &mut data)?; } cpio.finish()?.finish()?; Ok(()) }; let (status, _) = self.post("/Upload", Body::Stream(&mut produce), &headers)?; Ok(status == 200) } } /// Tells a callback how many bytes have passed. struct Reporting<'a, W: Write> { inner: W, sent: u64, report: &'a mut dyn FnMut(u64) -> bool, } impl<W: Write> Write for Reporting<'_, W> { fn write(&mut self, buf: &[u8]) -> io::Result<usize> { let n = self.inner.write(buf)?; self.sent += n as u64; if !(self.report)(self.sent) { return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); } Ok(n) } fn flush(&mut self) -> io::Result<()> { self.inner.flush() } } #[cfg(test)] mod tests { use super::*; #[test] fn utis_follow_the_extension() { assert_eq!(uti("photo.JPG"), "public.jpeg"); assert_eq!(uti("clip.mov"), "com.apple.quicktime-movie"); assert_eq!(uti(".txt"), "public.data"); assert_eq!(uti("archive.tar.gz"), "public.data"); assert_eq!(uti("README"), "public.data"); } #[test] fn transfer_ids_are_upper_case_uuid4s() { let a = transfer_id(); assert_eq!(a.len(), 36); assert_eq!(a, a.to_uppercase()); assert_eq!(&a[14..15], "4"); assert!(matches!(&a[19..20], "8" | "9" | "A" | "B")); assert_ne!(a, transfer_id()); } #[test] fn only_regular_files_are_sent() { let dir = tempfile::tempdir().unwrap(); assert!(Outgoing::new(dir.path()).is_err()); let f = dir.path().join("a b.txt"); std::fs::write(&f, b"hello").unwrap(); let o = Outgoing::new(&f).unwrap(); assert_eq!((o.name.as_str(), o.size), ("a b.txt", 5)); assert!(Outgoing::new(&dir.path().join("missing")).is_err()); } #[test] fn the_file_checked_is_the_file_sent() { use std::io::Read; let dir = tempfile::tempdir().unwrap(); let f = dir.path().join("photo.jpg"); std::fs::write(&f, b"the original").unwrap(); let o = Outgoing::new(&f).unwrap(); // The path is swapped after the check; the open file is not. let other = dir.path().join("other"); std::fs::write(&other, b"something else entirely").unwrap(); std::fs::rename(&other, &f).unwrap(); let mut got = Vec::new(); (&o.file).read_to_end(&mut got).unwrap(); assert_eq!(got, b"the original"); } #[test] fn a_fifo_is_refused_without_hanging() { let dir = tempfile::tempdir().unwrap(); let fifo = dir.path().join("pipe"); let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); // SAFETY: a valid NUL-terminated path. assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); let err = Outgoing::new(&fifo).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } #[test] fn replies_follow_the_status() { assert_eq!(Reply::from_status(200), Reply::Accepted); assert_eq!(Reply::from_status(406), Reply::Declined); assert_eq!(Reply::from_status(403), Reply::Refused); assert_eq!(Reply::from_status(500), Reply::Failed(500)); } }