1use super::error::{Error, peer_io};
4use super::net::{self, Wire};
5use super::proto::{
6 self, DeviceInfo, FileMeta, PrepareUploadRequest, PrepareUploadResponse, PulseExtra,
7};
8use std::collections::BTreeMap;
9use std::fs::File;
10use std::io::{self, Read, Write};
11use std::net::{IpAddr, TcpStream};
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::time::Duration;
15
16#[derive(Clone, Debug)]
18pub struct Peer {
19 pub ip: IpAddr,
20 pub port: u16,
21 pub https: bool,
22 pub fingerprint: String,
23 pub alias: String,
24}
25
26#[derive(Clone, Debug)]
28pub enum SendItem {
29 Path(PathBuf),
30 Text(String),
31}
32
33#[derive(Clone, Debug)]
34enum Source {
35 File(PathBuf),
36 Text(String),
37}
38
39#[derive(Clone, Debug)]
40pub struct Entry {
41 pub id: String,
42 pub name: String,
44 pub size: u64,
45 pub mime: String,
46 source: Source,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum Phase {
51 Waiting,
53 Sending,
54 Delivered,
57}
58
59#[derive(Clone, Debug)]
60pub struct Progress {
61 pub phase: Phase,
62 pub done: u64,
63 pub total: u64,
64 pub files_done: usize,
65 pub files_total: usize,
66 pub current: Option<String>,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum Outcome {
71 Done,
72 Declined,
73 Cancelled,
74}
75
76const MAX_ENTRIES: usize = 20_000;
77
78pub fn expand(items: &[SendItem]) -> Result<Vec<Entry>, Error> {
81 let mut entries: Vec<Entry> = Vec::new();
82 for item in items {
83 match item {
84 SendItem::Text(text) => {
85 if text.is_empty() {
86 continue;
87 }
88 entries.push(Entry {
89 id: format!("f{}", entries.len()),
90 name: format!("{}.txt", proto::random_hex(4)),
91 size: text.len() as u64,
92 mime: "text/plain".to_string(),
93 source: Source::Text(text.clone()),
94 });
95 }
96 SendItem::Path(path) => {
97 let metadata = std::fs::metadata(path).map_err(|e| Error::Unreadable {
98 path: path.display().to_string(),
99 detail: e.to_string(),
100 })?;
101 let name = path
102 .file_name()
103 .map(|n| n.to_string_lossy().into_owned())
104 .unwrap_or_else(|| "file".to_string());
105 if metadata.is_dir() {
106 walk(path, &name, &mut entries)?;
107 } else if metadata.is_file() {
108 push_file(&mut entries, path, name, metadata.len());
109 } else {
110 return Err(Error::NotFileOrFolder {
111 path: path.display().to_string(),
112 });
113 }
114 }
115 }
116 if entries.len() > MAX_ENTRIES {
117 return Err(Error::TooManyFiles { max: MAX_ENTRIES });
118 }
119 }
120 if entries.is_empty() {
121 return Err(Error::NothingToSend);
122 }
123 Ok(entries)
124}
125
126fn push_file(entries: &mut Vec<Entry>, path: &Path, name: String, size: u64) {
127 let mime = proto::mime_for(&name).to_string();
128 entries.push(Entry {
129 id: format!("f{}", entries.len()),
130 name,
131 size,
132 mime,
133 source: Source::File(path.to_path_buf()),
134 });
135}
136
137fn walk(directory: &Path, prefix: &str, entries: &mut Vec<Entry>) -> Result<(), Error> {
138 let mut stack = vec![(directory.to_path_buf(), prefix.to_string())];
139 while let Some((dir, relative)) = stack.pop() {
140 let listing = std::fs::read_dir(&dir).map_err(|e| Error::Unreadable {
141 path: dir.display().to_string(),
142 detail: e.to_string(),
143 })?;
144 for child in listing.flatten() {
145 let Ok(kind) = child.file_type() else {
146 continue;
147 };
148 let name = format!("{relative}/{}", child.file_name().to_string_lossy());
149 if kind.is_symlink() {
150 continue;
151 } else if kind.is_dir() {
152 stack.push((child.path(), name));
153 } else if kind.is_file() {
154 let size = child.metadata().map(|m| m.len()).unwrap_or(0);
155 push_file(entries, &child.path(), name, size);
156 }
157 if entries.len() > MAX_ENTRIES {
158 return Err(Error::TooManyFiles { max: MAX_ENTRIES });
159 }
160 }
161 }
162 Ok(())
163}
164
165fn exchange(
166 peer: &Peer,
167 target: &str,
168 json: Option<&[u8]>,
169 timeout: Duration,
170 register: &dyn Fn(Option<TcpStream>),
171 sent: &mut dyn FnMut(),
172) -> Result<net::Reply, Error> {
173 let mut wire = net::connect(peer.ip, peer.port, peer.https, &peer.fingerprint, timeout)
174 .map_err(|e| {
175 peer_io(&peer.alias, &e, |peer, detail| Error::PeerUnreachable {
176 peer,
177 detail,
178 })
179 })?;
180 register(wire.tcp().try_clone().ok());
181 let payload = json.unwrap_or(&[]);
182 let host = format!("{}:{}", peer.ip, peer.port);
183 let result = net::call(
184 &mut wire,
185 "POST",
186 &host,
187 target,
188 json.map(|_| "application/json"),
189 payload.len() as u64,
190 &mut |w| {
191 w.write_all(payload)?;
192 w.flush()?;
193 sent();
194 Ok(())
195 },
196 );
197 wire.finish();
198 register(None);
199 result.map_err(|e| {
200 peer_io(&peer.alias, &e, |peer, detail| Error::PeerLost {
201 peer,
202 detail,
203 })
204 })
205}
206
207fn query(session: &str, file: &str, token: &str) -> String {
208 format!(
209 "{}/upload?sessionId={}&fileId={}&token={}",
210 proto::API,
211 proto::url_encode(session),
212 proto::url_encode(file),
213 proto::url_encode(token)
214 )
215}
216
217pub fn deliver(
221 me: &DeviceInfo,
222 peer: &Peer,
223 entries: &[Entry],
224 clipboard: bool,
225 cancel: &AtomicBool,
226 register: &dyn Fn(Option<TcpStream>),
227 on: &mut dyn FnMut(Progress),
228) -> Result<Outcome, Error> {
229 if !peer.https {
231 return Err(Error::PeerPlaintext {
232 peer: peer.alias.clone(),
233 });
234 }
235 if !net::valid_pin(&peer.fingerprint) {
236 return Err(Error::InvalidIdentity {
237 peer: peer.alias.clone(),
238 });
239 }
240 let mut progress = Progress {
241 phase: Phase::Waiting,
242 done: 0,
243 total: entries.iter().map(|e| e.size).sum(),
244 files_done: 0,
245 files_total: entries.len(),
246 current: None,
247 };
248 on(progress.clone());
249
250 let mut files = BTreeMap::new();
251 for entry in entries {
252 files.insert(
253 entry.id.clone(),
254 FileMeta {
255 id: entry.id.clone(),
256 file_name: entry.name.clone(),
257 size: entry.size,
258 file_type: entry.mime.clone(),
259 sha256: None,
260 preview: match &entry.source {
261 Source::Text(text) => Some(text.clone()),
262 Source::File(_) => None,
263 },
264 metadata: None,
265 },
266 );
267 }
268 let mut info = me.clone();
269 info.announce = None;
270 let pulse = clipboard.then_some(PulseExtra { clipboard: true });
271 let body = serde_json::to_vec(&PrepareUploadRequest { info, files, pulse }).map_err(|e| {
272 Error::Internal {
273 detail: e.to_string(),
274 }
275 })?;
276 let prepare = format!("{}/prepare-upload", proto::API);
277 let reply = exchange(
280 peer,
281 &prepare,
282 Some(&body),
283 Duration::from_secs(190),
284 register,
285 &mut || {},
286 );
287 if cancel.load(Ordering::Relaxed) {
288 return Ok(Outcome::Cancelled);
289 }
290 let reply = reply?;
291 let session = match reply.status {
292 200 => serde_json::from_slice::<PrepareUploadResponse>(&reply.body).map_err(|_| {
293 Error::PeerReply {
294 peer: peer.alias.clone(),
295 }
296 })?,
297 204 => return Ok(Outcome::Done),
298 403 => return Ok(Outcome::Declined),
299 401 => {
300 return Err(Error::PeerNeedsPin {
301 peer: peer.alias.clone(),
302 });
303 }
304 409 => {
305 return Err(Error::PeerBusy {
306 peer: peer.alias.clone(),
307 });
308 }
309 413 => {
310 return Err(Error::PeerTooLarge {
311 peer: peer.alias.clone(),
312 });
313 }
314 429 => {
315 return Err(Error::PeerRateLimited {
316 peer: peer.alias.clone(),
317 });
318 }
319 other => {
320 return Err(Error::PeerAnswer {
321 peer: peer.alias.clone(),
322 status: other,
323 });
324 }
325 };
326 progress.files_total = entries
328 .iter()
329 .filter(|e| session.files.contains_key(&e.id))
330 .count();
331 progress.total = entries
332 .iter()
333 .filter(|e| session.files.contains_key(&e.id))
334 .map(|e| e.size)
335 .sum();
336 progress.phase = Phase::Sending;
337 on(progress.clone());
338
339 for entry in entries {
340 let Some(token) = session.files.get(&entry.id) else {
341 continue;
342 };
343 if cancel.load(Ordering::Relaxed) {
344 send_cancel(peer, &session.session_id);
345 return Ok(Outcome::Cancelled);
346 }
347 progress.current = Some(entry.name.clone());
348 on(progress.clone());
349 let target = query(&session.session_id, &entry.id, token);
350 let result = upload_one(peer, &target, entry, cancel, register, &mut progress, on);
351 match result {
352 Ok(()) => {
353 progress.files_done += 1;
354 on(progress.clone());
355 }
356 Err(error) => {
357 if cancel.load(Ordering::Relaxed) {
358 send_cancel(peer, &session.session_id);
359 return Ok(Outcome::Cancelled);
360 }
361 return Err(error);
362 }
363 }
364 }
365 Ok(Outcome::Done)
366}
367
368fn upload_one(
369 peer: &Peer,
370 target: &str,
371 entry: &Entry,
372 cancel: &AtomicBool,
373 register: &dyn Fn(Option<TcpStream>),
374 progress: &mut Progress,
375 on: &mut dyn FnMut(Progress),
376) -> Result<(), Error> {
377 let mut wire = net::connect(
378 peer.ip,
379 peer.port,
380 peer.https,
381 &peer.fingerprint,
382 Duration::from_secs(120),
383 )
384 .map_err(|e| {
385 peer_io(&peer.alias, &e, |peer, detail| Error::PeerUnreachable {
386 peer,
387 detail,
388 })
389 })?;
390 register(wire.tcp().try_clone().ok());
391 let host = format!("{}:{}", peer.ip, peer.port);
392 let result = net::call(
393 &mut wire,
394 "POST",
395 &host,
396 target,
397 Some("application/octet-stream"),
398 entry.size,
399 &mut |w: &mut Wire| match &entry.source {
400 Source::Text(text) => {
401 w.write_all(text.as_bytes())?;
402 progress.done += text.len() as u64;
403 on(progress.clone());
404 Ok(())
405 }
406 Source::File(path) => {
407 let mut file = File::open(path)?;
408 let mut buffer = vec![0u8; 64 * 1024];
409 let mut remaining = entry.size;
410 while remaining > 0 {
411 if cancel.load(Ordering::Relaxed) {
412 return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
413 }
414 let want = remaining.min(buffer.len() as u64) as usize;
415 let n = file.read(&mut buffer[..want])?;
416 if n == 0 {
417 return Err(io::Error::new(
418 io::ErrorKind::UnexpectedEof,
419 "the file got shorter",
420 ));
421 }
422 w.write_all(&buffer[..n])?;
423 remaining -= n as u64;
424 progress.done += n as u64;
425 on(progress.clone());
426 }
427 Ok(())
428 }
429 },
430 );
431 wire.finish();
432 register(None);
433 let reply = result.map_err(|e| {
434 peer_io(&peer.alias, &e, |_, detail| Error::FileStopped {
435 file: entry.name.clone(),
436 detail,
437 })
438 })?;
439 match reply.status {
440 200 | 204 => Ok(()),
441 403 => Err(Error::FileRefused {
442 peer: peer.alias.clone(),
443 file: entry.name.clone(),
444 }),
445 422 => Err(Error::FileDamaged {
446 peer: peer.alias.clone(),
447 file: entry.name.clone(),
448 }),
449 other => Err(Error::PeerAnswer {
450 peer: peer.alias.clone(),
451 status: other,
452 }),
453 }
454}
455
456fn send_cancel(peer: &Peer, session: &str) {
458 let target = format!(
459 "{}/cancel?sessionId={}",
460 proto::API,
461 proto::url_encode(session)
462 );
463 let _ = net::request_json(
464 peer.ip,
465 peer.port,
466 peer.https,
467 &peer.fingerprint,
468 "POST",
469 &target,
470 None,
471 Duration::from_secs(5),
472 );
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn me() -> DeviceInfo {
480 DeviceInfo {
481 alias: "Me".into(),
482 version: "2.0".into(),
483 device_model: None,
484 device_type: None,
485 fingerprint: "f".repeat(64),
486 port: 53317,
487 protocol: "https".into(),
488 download: false,
489 announce: None,
490 }
491 }
492
493 #[test]
494 fn nothing_is_sent_over_plain_http() {
495 let peer = Peer {
496 ip: "127.0.0.1".parse().unwrap(),
497 port: 9,
498 https: false,
499 fingerprint: "a".repeat(64),
500 alias: "Plain".into(),
501 };
502 let entries = expand(&[SendItem::Text("hi".into())]).unwrap();
503 let cancel = AtomicBool::new(false);
504 let result = deliver(&me(), &peer, &entries, false, &cancel, &|_| {}, &mut |_| {});
505 assert_eq!(
506 result,
507 Err(Error::PeerPlaintext {
508 peer: "Plain".into()
509 })
510 );
511 }
512
513 #[test]
514 fn a_peer_without_a_valid_fingerprint_is_refused_before_connecting() {
515 let peer = Peer {
516 ip: "127.0.0.1".parse().unwrap(),
517 port: 9,
518 https: true,
519 fingerprint: "not-hex".into(),
520 alias: "Odd".into(),
521 };
522 let entries = expand(&[SendItem::Text("hi".into())]).unwrap();
523 let cancel = AtomicBool::new(false);
524 let result = deliver(&me(), &peer, &entries, false, &cancel, &|_| {}, &mut |_| {});
525 assert_eq!(result, Err(Error::InvalidIdentity { peer: "Odd".into() }));
526 }
527
528 #[test]
529 fn empty_selections_and_missing_paths_are_typed() {
530 assert!(matches!(expand(&[]), Err(Error::NothingToSend)));
531 assert!(matches!(
532 expand(&[SendItem::Path("/definitely/not/here/rightkit".into())]),
533 Err(Error::Unreadable { .. })
534 ));
535 }
536}