~admicos/moonlander

ref: dde394f1b7b8076ad1de98045f46460e524af11c moonlander/gemini/src/lib.rs -rw-r--r-- 6.5 KiB
dde394f1Ecmel Berk Canlier gemini: Make clippy happy a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
#![warn(clippy::pedantic, clippy::nursery)]
#![allow(
    clippy::missing_errors_doc, // i am bad at docs
    clippy::must_use_candidate, // thanks for the info

    // that is not a creative name
    clippy::pub_enum_variant_names,
    clippy::module_name_repetitions,
)]

mod chunk;
mod clientcert;
mod error;
mod header;

pub use chunk::*;
pub use error::{Error, Result};
pub use header::*;
use tofu::TOFUVerifier;

use directories_next::ProjectDirs;
use once_cell::sync::Lazy;
use rustls::{ClientSession, Session, Stream};

use std::{
    io::{Read, Write},
    net::TcpStream,
    sync::Arc,
};

const DEFAULT_PORT: u16 = 1965;

// crafted specifically to pull all of the Gemini header into memory. this avoids
// having to deal with multiple reads for that.
// data will be longer, and we do deal with that correctly. this is just a dumb
// shortcut for the header
//
// max length of <META> = 1024
// <STATUS> = 2
// separator space = 1 , ending crlf = 2
const BUFFER_SIZE: usize = 1029;

static DIR: Lazy<Option<ProjectDirs>> =
    Lazy::new(|| ProjectDirs::from("com", "ecmelberk", "Moonlander"));

static TOFU_DIR: Lazy<std::path::PathBuf> = Lazy::new(|| {
    DIR.clone().map_or_else(|| {
        log::warn!("Could not detect appropriate system directories. Using the current directory to store TOFU data");
        std::env::current_dir().expect("Cannot see the current directory!")
    }, |d| {
        let mut d = d.data_dir().to_owned();
        d.push("tofu");
        d
    })
});

static TOFU: Lazy<Arc<TOFUVerifier>> = Lazy::new(|| Arc::new(TOFUVerifier::new(&TOFU_DIR)));

/// Send a Gemini request to the given url. The results are slowly returned
/// through the callback using [`Chunk`]s.
/// ```no_run
/// # use url::Url;
/// let url = Url::parse("gemini://ebc.li").unwrap();
/// gemini::request(&url, |chunk| {
///     match chunk {
///         gemini::Chunk::Header(hdr) => println!("Got Header: {:?}", hdr),
///         gemini::Chunk::Data(data) => println!("Got some data: {:?}", data),
///         gemini::Chunk::Done => {
///             println!("Connection closed");
///             std::process::exit(0);
///         },
///     };
///
///     gemini::ChunkResponse::Continue
/// })
/// .unwrap();
/// # std::thread::sleep(std::time::Duration::from_secs(30)); // I hope the connection finished in 30 seconds!
/// ```
pub fn request(url: &url::Url, callback: impl Fn(Chunk) -> ChunkResponse) -> Result<()> {
    log::info!("Sending request to {}", url);

    let mut url = url.clone();
    if url.scheme().is_empty() {
        url.set_scheme("gemini")
            .map_err(|_| error::Error::UrlSchemeError)?;
    }

    // Do not send a fragment to the server
    if url.fragment().is_some() {
        url.set_fragment(None);
    }

    let port = url.port().unwrap_or(DEFAULT_PORT);
    let host = url
        .host_str()
        .ok_or_else(|| error::Error::UrlNoHostError(url.to_string()))?;

    let tls_config = {
        Arc::new({
            let mut cfg = rustls::ClientConfig::new();
            cfg.dangerous().set_certificate_verifier(TOFU.clone());

            // Gemini closes sessions immediately
            cfg.set_persistence(Arc::new(rustls::NoClientSessionStorage {}));

            if let Some((c, p)) = clientcert::CertResolver::new(host).resolve() {
                cfg.set_single_client_cert(vec![c], p)?;
            }

            cfg
        })
    };

    let dns = webpki::DNSNameRef::try_from_ascii_str(host)?;

    let mut raw = TcpStream::connect((host, port))?;
    let mut tls = ClientSession::new(&tls_config, dns);
    let mut stream = Stream::new(&mut tls, &mut raw);

    log::debug!("TLS connection OK. Sending request");

    // this needs to be write_all, using the write! macro fails on gmnisrv-based servers
    // (and probably some others, too)
    // guess how long that took me to figure out
    if let Err(e) = stream.write_all(format!("{}\r\n", url).as_bytes()) {
        let tofu = TOFU.tofu.lock().unwrap();
        return tofu.certs.get(&host.to_lowercase()).map_or_else(
            || Err(e.into()),
            |expected| {
                Err(Error::UntrustedCertificate {
                    host: host.to_string(),
                    algorithm: expected.algorithm.clone(),
                    expected_fingerprint: expected.fingerprint.clone(),
                })
            },
        );
    }

    let mut buf: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
    let mut ret = Ok(());
    let mut received_response_meta = false;

    log::debug!("Receiving response");
    loop {
        let len;

        match stream.read(&mut buf) {
            Ok(l) => len = l,
            Err(e) => {
                if e.kind() == std::io::ErrorKind::ConnectionAborted {
                    log::warn!(
                        "Connection Aborted. Some servers do this intentionally. Returning Ok"
                    );
                } else {
                    ret = Err(e.into());
                }

                break;
            }
        }

        if len == 0 {
            break;
        }

        let mut data = &buf[0..len];
        let mut send_data = true;

        if !received_response_meta {
            log::debug!("Receiving metadata");

            let stringify = String::from_utf8_lossy(data);
            let header_str = stringify
                .splitn(2, "\r\n")
                .next()
                .ok_or(error::Error::ResponseNoHeader)?;

            log::debug!("Parsing header");
            let header = Header::parse(header_str)?;

            if let Header::Success { mime: _ } = &header {
                // only the header is likely to be UTF-8 compatible. The rest can
                // be arbitrary bytes. "+ 2" is added to compensate for \r\n
                data = &data[header_str.len() + 2..len];
            } else {
                // only the success response returns any body data.
                send_data = false;
            }

            log::info!("Response: {:?}", header);
            if let ChunkResponse::Abort = callback(Chunk::Header(header)) {
                log::info!("Callback has aborted this connection");
                break;
            }

            received_response_meta = true;
        }

        if send_data {
            if let ChunkResponse::Abort = callback(Chunk::Data(data.to_vec())) {
                log::info!("Callback has aborted this connection");
                break;
            }
        } else {
            break;
        }
    }

    callback(Chunk::Done);

    // Close connection
    tls.send_close_notify();
    tls.write_tls(&mut raw)?;

    ret
}