magic_wormhole/
transfer.rs

1//! Client-to-Client protocol to organize file transfers
2//!
3//! This gives you the actual capability to transfer files, that feature that Magic Wormhole got known and loved for.
4//!
5//! It is bound to an [`AppID`]. Only applications using that APPID (and thus this protocol) can interoperate with
6//! the original Python implementation (and other compliant implementations).
7//!
8//! At its core, "peer messages" are exchanged over an established wormhole connection with the other side.
9//! They are used to set up a [transit] portal and to exchange a file offer/accept. Then, the file is transmitted over the transit relay.
10
11#![allow(deprecated)]
12
13use futures::{AsyncRead, AsyncWrite};
14use serde_derive::{Deserialize, Serialize};
15#[cfg(test)]
16use serde_json::json;
17use std::sync::Arc;
18
19use super::{core::WormholeError, transit, AppID, Wormhole};
20use futures::Future;
21use std::{borrow::Cow, collections::BTreeMap};
22
23#[cfg(not(target_family = "wasm"))]
24use std::path::{Path, PathBuf};
25
26use transit::{
27    Abilities as TransitAbilities, Transit, TransitConnectError, TransitConnector, TransitError,
28};
29
30mod cancel;
31#[doc(hidden)]
32pub mod offer;
33mod v1;
34#[cfg(feature = "experimental-transfer-v2")]
35#[allow(missing_docs)]
36mod v2;
37
38#[doc(hidden)]
39pub use v1::ReceiveRequest as ReceiveRequestV1;
40
41#[cfg(not(feature = "experimental-transfer-v2"))]
42pub use v1::ReceiveRequest;
43
44#[cfg(feature = "experimental-transfer-v2")]
45pub use v2::ReceiveRequest as ReceiveRequestV2;
46
47const APPID_RAW: &str = "lothar.com/wormhole/text-or-file-xfer";
48
49/// The App ID associated with this protocol.
50pub const APPID: AppID = AppID(Cow::Borrowed(APPID_RAW));
51
52/// An [`crate::AppConfig`] with default parameters for the file transfer protocol.
53///
54/// You **must not** change `id` and `rendezvous_url` to be interoperable.
55/// The `app_version` can be adjusted if you want to disable some features.
56pub const APP_CONFIG: crate::AppConfig<AppVersion> = crate::AppConfig::<AppVersion> {
57    id: AppID(Cow::Borrowed(APPID_RAW)),
58    rendezvous_url: Cow::Borrowed(crate::rendezvous::DEFAULT_RENDEZVOUS_SERVER),
59    app_version: AppVersion::new(),
60};
61
62// TODO be more extensible on the JSON enum types (i.e. recognize unknown variants)
63
64#[derive(Debug, thiserror::Error)]
65#[non_exhaustive]
66/// An error occurred during file transfer
67pub enum TransferError {
68    /// Transfer was not acknowledged by peer
69    #[error("Transfer was not acknowledged by peer")]
70    AckError,
71
72    /// Receive checksum error
73    #[error("Receive checksum error")]
74    Checksum,
75
76    /// The file contained a different amount of bytes than advertized
77    #[error("The file contained a different amount of bytes than advertized! Sent {} bytes, but should have been {}", sent_size, file_size)]
78    FileSize {
79        /// The amount of bytes that were sent
80        sent_size: u64,
81        /// The expected amount of bytes
82        file_size: u64,
83    },
84
85    /// The file(s) to send got modified during the transfer, and thus corrupted
86    #[error("The file(s) to send got modified during the transfer, and thus corrupted")]
87    FilesystemSkew,
88
89    // TODO be more specific
90    /// Unsupported offer type
91    #[error("Unsupported offer type")]
92    UnsupportedOffer,
93
94    /// Something went wrong on the other side
95    #[error("Something went wrong on the other side: {}", _0)]
96    PeerError(String),
97
98    /// Corrupt JSON message received. Some deserialization went wrong, we probably got some garbage
99    #[error("Corrupt JSON message received")]
100    ProtocolJson(
101        #[from]
102        #[source]
103        serde_json::Error,
104    ),
105
106    /// Corrupt Msgpack message received. Some deserialization went wrong, we probably got some garbage
107    #[error("Corrupt Msgpack message received")]
108    ProtocolMsgpack(
109        #[from]
110        #[source]
111        rmp_serde::decode::Error,
112    ),
113
114    /// A generic string message for "something went wrong", i.e.
115    /// the server sent some bullshit message order
116    #[error("Protocol error: {}", _0)]
117    Protocol(Box<str>),
118
119    /// Unexpected message (protocol error)
120    #[error(
121        "Unexpected message (protocol error): Expected '{}', but got: '{}'",
122        _0,
123        _1
124    )]
125    ProtocolUnexpectedMessage(Box<str>, Box<str>),
126
127    /// Wormhole connection error
128    #[error("Wormhole connection error")]
129    Wormhole(
130        #[from]
131        #[source]
132        WormholeError,
133    ),
134
135    /// Error while establishing transit connection
136    #[error("Error while establishing transit connection")]
137    TransitConnect(
138        #[from]
139        #[source]
140        TransitConnectError,
141    ),
142
143    /// Transit error
144    #[error("Transit error")]
145    Transit(
146        #[from]
147        #[source]
148        TransitError,
149    ),
150
151    /// I/O error
152    #[error("I/O error")]
153    IO(
154        #[from]
155        #[source]
156        std::io::Error,
157    ),
158}
159
160impl TransferError {
161    pub(self) fn unexpected_message(
162        expected: impl Into<Box<str>>,
163        got: impl std::fmt::Display,
164    ) -> Self {
165        Self::ProtocolUnexpectedMessage(expected.into(), got.to_string().into())
166    }
167}
168
169/**
170 * The application specific version information for this protocol.
171 */
172#[derive(Clone, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub struct AppVersion {
175    #[serde(default)]
176    abilities: Cow<'static, [Cow<'static, str>]>,
177    #[serde(default)]
178    #[cfg(feature = "experimental-transfer-v2")]
179    transfer_v2: Option<AppVersionTransferV2Hint>,
180}
181
182// TODO check invariants during deserialization
183impl AppVersion {
184    const fn new() -> Self {
185        Self {
186            // Dont advertize v2 for now
187            abilities: Cow::Borrowed(&[
188                Cow::Borrowed("transfer-v1"), /* Cow::Borrowed("experimental-transfer-v2") */
189            ]),
190            #[cfg(feature = "experimental-transfer-v2")]
191            transfer_v2: Some(AppVersionTransferV2Hint::new()),
192        }
193    }
194
195    #[allow(dead_code)]
196    fn supports_v2(&self) -> bool {
197        self.abilities.contains(&"transfer-v2".into())
198    }
199}
200
201impl Default for AppVersion {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// A hint used in transfer v2 to determine the app version
208#[cfg(feature = "experimental-transfer-v2")]
209#[derive(Clone, Debug, Serialize, Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub struct AppVersionTransferV2Hint {
212    supported_formats: Cow<'static, [Cow<'static, str>]>,
213    transit_abilities: transit::Abilities,
214}
215
216#[cfg(feature = "experimental-transfer-v2")]
217impl AppVersionTransferV2Hint {
218    const fn new() -> Self {
219        Self {
220            supported_formats: Cow::Borrowed(&[Cow::Borrowed("plain"), Cow::Borrowed("tar")]),
221            transit_abilities: transit::Abilities::ALL_ABILITIES,
222        }
223    }
224}
225
226#[cfg(feature = "experimental-transfer-v2")]
227impl Default for AppVersionTransferV2Hint {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233/**
234 * The type of message exchanged over the wormhole for this protocol
235 */
236#[derive(Deserialize, Serialize, derive_more::Display, Debug, Clone)]
237#[serde(rename_all = "kebab-case")]
238#[non_exhaustive]
239#[deprecated(
240    since = "0.7.0",
241    note = "This will be a private type in the future. Open an issue if you require access to protocol intrinsics in the future"
242)]
243pub enum PeerMessage {
244    /* V1 */
245    /// A transit message
246    #[display("transit")]
247    Transit(v1::TransitV1),
248
249    /// An offer message
250    #[display("offer")]
251    Offer(v1::OfferMessage),
252
253    /// An answer message
254    #[display("answer")]
255    Answer(v1::AnswerMessage),
256    /* V2 */
257    /// A transit v2 message
258    #[cfg(feature = "experimental-transfer-v2")]
259    #[display("transit-v2")]
260    TransitV2(v2::TransitV2),
261
262    /// Tell the other side you got an error
263    #[display("error")]
264    Error(String),
265
266    /// An unknown message
267    #[display("unknown")]
268    #[serde(other)]
269    Unknown,
270}
271
272impl PeerMessage {
273    #[allow(unused)]
274    fn offer_message_v1(msg: impl Into<String>) -> Self {
275        PeerMessage::Offer(v1::OfferMessage::Message(msg.into()))
276    }
277
278    fn offer_file_v1(name: impl Into<String>, size: u64) -> Self {
279        PeerMessage::Offer(v1::OfferMessage::File {
280            filename: name.into(),
281            filesize: size,
282        })
283    }
284
285    #[allow(dead_code)]
286    fn offer_directory_v1(
287        name: impl Into<String>,
288        mode: impl Into<String>,
289        compressed_size: u64,
290        numbytes: u64,
291        numfiles: u64,
292    ) -> Self {
293        PeerMessage::Offer(v1::OfferMessage::Directory {
294            dirname: name.into(),
295            mode: mode.into(),
296            zipsize: compressed_size,
297            numbytes,
298            numfiles,
299        })
300    }
301
302    #[allow(dead_code)]
303    fn message_ack_v1(msg: impl Into<String>) -> Self {
304        PeerMessage::Answer(v1::AnswerMessage::MessageAck(msg.into()))
305    }
306
307    fn file_ack_v1(msg: impl Into<String>) -> Self {
308        PeerMessage::Answer(v1::AnswerMessage::FileAck(msg.into()))
309    }
310
311    fn error_message(msg: impl Into<String>) -> Self {
312        PeerMessage::Error(msg.into())
313    }
314
315    fn transit_v1(abilities: TransitAbilities, hints: transit::Hints) -> Self {
316        PeerMessage::Transit(v1::TransitV1 {
317            abilities_v1: abilities,
318            hints_v1: hints,
319        })
320    }
321
322    #[cfg(feature = "experimental-transfer-v2")]
323    fn transit_v2(hints_v2: transit::Hints) -> Self {
324        PeerMessage::TransitV2(v2::TransitV2 { hints_v2 })
325    }
326
327    fn check_err(&self) -> Result<Self, TransferError> {
328        match self {
329            Self::Error(err) => Err(TransferError::PeerError(err.clone())),
330            other => Ok(other.clone()),
331        }
332    }
333
334    #[allow(dead_code)]
335    fn ser_json(&self) -> Vec<u8> {
336        serde_json::to_vec(self).unwrap()
337    }
338}
339
340/// Send a previously constructed offer.
341///
342/// Part of the experimental and unstable transfer-v2 API.
343/// Expect some amount of API breakage in the future to adapt to protocol changes and API ergonomics.
344#[cfg_attr(not(feature = "experimental-transfer-v2"), doc(hidden))]
345pub async fn send(
346    wormhole: Wormhole,
347    relay_hints: Vec<transit::RelayHint>,
348    transit_abilities: transit::Abilities,
349    offer: offer::OfferSend,
350    transit_handler: impl FnOnce(transit::TransitInfo),
351    progress_handler: impl FnMut(u64, u64) + 'static,
352    cancel: impl Future<Output = ()>,
353) -> Result<(), TransferError> {
354    let peer_version: AppVersion = serde_json::from_value(wormhole.peer_version().clone())?;
355
356    #[cfg(feature = "experimental-transfer-v2")]
357    {
358        if peer_version.supports_v2() {
359            return v2::send(
360                wormhole,
361                relay_hints,
362                transit_abilities,
363                offer,
364                progress_handler,
365                peer_version,
366                cancel,
367            )
368            .await;
369        }
370    }
371
372    v1::send(
373        wormhole,
374        relay_hints,
375        transit_abilities,
376        offer,
377        progress_handler,
378        transit_handler,
379        peer_version,
380        cancel,
381    )
382    .await
383}
384
385/**
386 * Wait for a file offer from the other side
387 *
388 * This method waits for an offer message and builds up a [`ReceiveRequest`].
389 * It will also start building a TCP connection to the other side using the transit protocol.
390 *
391 * Returns `None` if the task got cancelled.
392 *
393 * Part of the experimental and unstable transfer-v2 API.
394 * Expect some amount of API breakage in the future to adapt to protocol changes and API ergonomics.
395 */
396#[cfg(feature = "experimental-transfer-v2")]
397pub async fn request(
398    wormhole: Wormhole,
399    relay_hints: Vec<transit::RelayHint>,
400    transit_abilities: transit::Abilities,
401    cancel: impl Future<Output = ()>,
402) -> Result<Option<ReceiveRequest>, TransferError> {
403    #[cfg(feature = "experimental-transfer-v2")]
404    {
405        let peer_version: AppVersion = serde_json::from_value(wormhole.peer_version().clone())?;
406        if peer_version.supports_v2() {
407            v2::request(
408                wormhole,
409                relay_hints,
410                peer_version,
411                transit_abilities,
412                cancel,
413            )
414            .await
415            .map(|req| req.map(ReceiveRequest::V2))
416        } else {
417            v1::request(wormhole, relay_hints, transit_abilities, cancel)
418                .await
419                .map(|req| req.map(ReceiveRequest::V1))
420        }
421    }
422}
423
424/// Wait for a file offer from the other side
425///
426/// This method waits for an offer message and builds up a ReceiveRequest. It will also start building a TCP connection to the other side using the transit protocol.
427///
428/// Returns None if the task got cancelled.
429#[cfg_attr(
430    feature = "experimental-transfer-v2",
431    deprecated(
432        since = "0.7.0",
433        note = "transfer::request_file does not support file transfer protocol version 2.
434        To continue only supporting version 1, use transfer::v1::request. To support both protocol versions, use transfer::request"
435    )
436)]
437pub async fn request_file(
438    wormhole: Wormhole,
439    relay_hints: Vec<transit::RelayHint>,
440    transit_abilities: transit::Abilities,
441    cancel: impl Future<Output = ()>,
442) -> Result<Option<v1::ReceiveRequest>, TransferError> {
443    v1::request(wormhole, relay_hints, transit_abilities, cancel).await
444}
445
446/// Send a file to the other side
447///
448/// You must ensure that the Reader contains exactly as many bytes as advertized in file_size.
449#[cfg_attr(
450    feature = "experimental-transfer-v2",
451    deprecated(
452        since = "0.7.0",
453        note = "transfer::send_file does not support file transfer protocol version 2, use transfer::send"
454    )
455)]
456pub async fn send_file<F, N, G, H>(
457    wormhole: Wormhole,
458    relay_hints: Vec<transit::RelayHint>,
459    file: &mut F,
460    file_name: N,
461    file_size: u64,
462    transit_abilities: transit::Abilities,
463    transit_handler: G,
464    progress_handler: H,
465    cancel: impl Future<Output = ()>,
466) -> Result<(), TransferError>
467where
468    F: AsyncRead + Unpin + Send,
469    N: Into<String>,
470    G: FnOnce(transit::TransitInfo),
471    H: FnMut(u64, u64) + 'static,
472{
473    v1::send_file(
474        wormhole,
475        relay_hints,
476        file,
477        file_name,
478        file_size,
479        transit_abilities,
480        transit_handler,
481        progress_handler,
482        cancel,
483    )
484    .await
485}
486
487/// Send a file or folder
488#[cfg_attr(
489    feature = "experimental-transfer-v2",
490    deprecated(
491        since = "0.7.0",
492        note = "transfer::send_file_or_folder does not support file transfer protocol version 2, use transfer::send"
493    )
494)]
495#[allow(deprecated)]
496#[cfg(not(target_family = "wasm"))]
497pub async fn send_file_or_folder<N, M, G, H>(
498    wormhole: Wormhole,
499    relay_hints: Vec<transit::RelayHint>,
500    file_path: N,
501    file_name: M,
502    transit_abilities: transit::Abilities,
503    transit_handler: G,
504    progress_handler: H,
505    cancel: impl Future<Output = ()>,
506) -> Result<(), TransferError>
507where
508    N: AsRef<Path>,
509    M: Into<String>,
510    G: FnOnce(transit::TransitInfo),
511    H: FnMut(u64, u64) + 'static,
512{
513    use async_std::fs::File;
514    let file_path = file_path.as_ref();
515    let file_name = file_name.into();
516
517    let mut file = File::open(file_path).await?;
518    let metadata = file.metadata().await?;
519    if metadata.is_dir() {
520        send_folder(
521            wormhole,
522            relay_hints,
523            file_path,
524            file_name,
525            transit_abilities,
526            transit_handler,
527            progress_handler,
528            cancel,
529        )
530        .await?;
531    } else {
532        let file_size = metadata.len();
533        send_file(
534            wormhole,
535            relay_hints,
536            &mut file,
537            file_name,
538            file_size,
539            transit_abilities,
540            transit_handler,
541            progress_handler,
542            cancel,
543        )
544        .await?;
545    }
546    Ok(())
547}
548
549/// Send a folder to the other side
550/// This isn’t a proper folder transfer as per the Wormhole protocol because it sends it in a way so
551/// that the receiver still has to manually unpack it. But it’s better than nothing
552#[cfg_attr(
553    feature = "experimental-transfer-v2",
554    deprecated(
555        since = "0.7.0",
556        note = "transfer::send_folder does not support file transfer protocol version 2, use transfer::send"
557    )
558)]
559#[cfg(not(target_family = "wasm"))]
560pub async fn send_folder<N, M, G, H>(
561    wormhole: Wormhole,
562    relay_hints: Vec<transit::RelayHint>,
563    folder_path: N,
564    folder_name: M,
565    transit_abilities: transit::Abilities,
566    transit_handler: G,
567    progress_handler: H,
568    cancel: impl Future<Output = ()>,
569) -> Result<(), TransferError>
570where
571    N: Into<PathBuf>,
572    M: Into<String>,
573    G: FnOnce(transit::TransitInfo),
574    H: FnMut(u64, u64) + 'static,
575{
576    let offer = offer::OfferSendEntry::new(folder_path.into()).await?;
577
578    v1::send_folder(
579        wormhole,
580        relay_hints,
581        folder_name.into(),
582        offer,
583        transit_abilities,
584        transit_handler,
585        progress_handler,
586        cancel,
587    )
588    .await
589}
590
591/**
592 * A pending files send offer from the other side
593 *
594 * You *should* consume this object, by matching on the protocol version and then calling either `accept` or `reject`.
595 */
596#[must_use]
597#[cfg(feature = "experimental-transfer-v2")]
598pub enum ReceiveRequest {
599    /// A protocol version 1 receive request
600    V1(ReceiveRequestV1),
601    /// A protocol version 2 receive request
602    V2(ReceiveRequestV2),
603}
604
605#[cfg(feature = "experimental-transfer-v2")]
606impl ReceiveRequest {
607    /// Accept this receive request
608    pub async fn accept<F, G, W>(
609        self,
610        transit_handler: G,
611        progress_handler: F,
612        mut answer: offer::OfferAccept,
613        cancel: impl Future<Output = ()>,
614    ) -> Result<(), TransferError>
615    where
616        F: FnMut(u64, u64) + 'static,
617        G: FnOnce(transit::TransitInfo),
618        W: AsyncWrite + Unpin,
619    {
620        match self {
621            ReceiveRequest::V1(request) => {
622                // Desynthesize the previously synthesized offer to make transfer v1 more similar to transfer v2
623                let (_name, entry) = answer.content.pop_first().expect(
624                    "must call accept(..) with an offer that contains at least one element",
625                );
626
627                let mut acceptor = match entry {
628                    offer::OfferEntry::RegularFile { content, .. } => {
629                        (content.content)(true).await?
630                    },
631                    _ => panic!(
632                        "when using transfer v1 you must call accept(..) with file offers only",
633                    ),
634                };
635
636                request
637                    .accept(transit_handler, progress_handler, &mut acceptor, cancel)
638                    .await
639            },
640            ReceiveRequest::V2(request) => {
641                request
642                    .accept(transit_handler, answer, progress_handler, cancel)
643                    .await
644            },
645        }
646    }
647
648    /**
649     * Reject the file offer
650     *
651     * This will send an error message to the other side so that it knows the transfer failed.
652     */
653    pub async fn reject(self) -> Result<(), TransferError> {
654        match self {
655            ReceiveRequest::V1(request) => request.reject().await,
656            ReceiveRequest::V2(request) => request.reject().await,
657        }
658    }
659
660    /// The file offer for this receive request
661    pub fn offer(&self) -> Arc<offer::Offer> {
662        match self {
663            ReceiveRequest::V1(req) => req.offer(),
664            ReceiveRequest::V2(req) => req.offer(),
665        }
666    }
667}
668
669#[cfg(test)]
670mod test {
671    use super::*;
672    use transit::{Abilities, DirectHint, RelayHint};
673
674    #[test]
675    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
676    fn test_transit() {
677        let abilities = Abilities::ALL_ABILITIES;
678        let hints = transit::Hints::new(
679            [DirectHint::new("192.168.1.8", 46295)],
680            [RelayHint::new(
681                None,
682                [DirectHint::new("magic-wormhole-transit.debian.net", 4001)],
683                [],
684            )],
685        );
686        assert_eq!(
687            serde_json::json!(crate::transfer::PeerMessage::transit_v1(abilities, hints)),
688            serde_json::json!({
689                "transit": {
690                    "abilities-v1": [{"type":"direct-tcp-v1"},{"type":"relay-v1"}],
691                    "hints-v1": [
692                        {"hostname":"192.168.1.8","port":46295,"type":"direct-tcp-v1"},
693                        {
694                            "type": "relay-v1",
695                            "hints": [
696                                {"type": "direct-tcp-v1", "hostname": "magic-wormhole-transit.debian.net", "port": 4001}
697                            ],
698                            "name": null
699                        }
700                    ],
701                }
702            })
703        );
704    }
705
706    #[test]
707    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
708    fn test_message() {
709        let m1 = PeerMessage::offer_message_v1("hello from rust");
710        assert_eq!(
711            serde_json::json!(m1).to_string(),
712            "{\"offer\":{\"message\":\"hello from rust\"}}"
713        );
714    }
715
716    #[test]
717    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
718    fn test_offer_file() {
719        let f1 = PeerMessage::offer_file_v1("somefile.txt", 34556);
720        assert_eq!(
721            serde_json::json!(f1).to_string(),
722            "{\"offer\":{\"file\":{\"filename\":\"somefile.txt\",\"filesize\":34556}}}"
723        );
724    }
725
726    #[test]
727    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
728    fn test_offer_directory() {
729        let d1 = PeerMessage::offer_directory_v1("somedirectory", "zipped", 45, 1234, 10);
730        assert_eq!(
731            serde_json::json!(d1).to_string(),
732            "{\"offer\":{\"directory\":{\"dirname\":\"somedirectory\",\"mode\":\"zipped\",\"numbytes\":1234,\"numfiles\":10,\"zipsize\":45}}}"
733        );
734    }
735
736    #[test]
737    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
738    fn test_message_ack() {
739        let m1 = PeerMessage::message_ack_v1("ok");
740        assert_eq!(
741            serde_json::json!(m1).to_string(),
742            "{\"answer\":{\"message_ack\":\"ok\"}}"
743        );
744    }
745
746    #[test]
747    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
748    fn test_file_ack() {
749        let f1 = PeerMessage::file_ack_v1("ok");
750        assert_eq!(
751            serde_json::json!(f1).to_string(),
752            "{\"answer\":{\"file_ack\":\"ok\"}}"
753        );
754    }
755}