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