http_timings/
lib.rs

1#![deny(missing_docs)]
2#![deny(missing_debug_implementations)]
3#![deny(rustdoc::all)]
4#![deny(clippy::all)]
5#![deny(clippy::pedantic)]
6#![deny(clippy::cargo)]
7#![deny(clippy::unwrap_used)]
8#![allow(clippy::cast_sign_loss)]
9#![allow(clippy::cast_possible_wrap)]
10
11//! Obtain the HTTP timings for any given URL
12//!
13//! This crate provides a way to measure the HTTP timings for any given URL.
14//! This crate also provides basic and the certificate information for the given URL.
15//!
16//! Example:
17//! ```rust
18//! use http_timings::from_string;
19//! use std::time::Duration;
20//!
21//! let url = "https://www.example.com";
22//! let timeout = Some(Duration::from_secs(5)); // Set a timeout of 5 seconds
23//! match from_string(url, timeout) {
24//!     Ok(response) => {
25//!         println!("Response Status: {}", response.status);
26//!         println!("Response Body: {}", response.body.string());
27//!         if let Some(cert_info) = response.certificate_information {
28//!             println!("Certificate Subject: {:?}", cert_info.subject);
29//!             println!("Certificate Issued At: {:?}", cert_info.issued_at);
30//!             println!("Certificate Expires At: {:?}", cert_info.expires_at);
31//!             println!("Is Certificate Active: {:?}", cert_info.is_active);
32//!         } else {
33//!             println!("No certificate information available.");
34//!         }
35//!     },
36//!     Err(e) => {
37//!         eprintln!("Error occurred: {:?}", e);
38//!     }
39//! }
40//! ```
41
42use std::{
43    fmt::Debug,
44    io::{BufRead, BufReader, Read, Write},
45    net::{SocketAddr, TcpStream, ToSocketAddrs},
46    time::{Duration, SystemTime, UNIX_EPOCH},
47    vec::IntoIter,
48};
49
50use flate2::read::{DeflateDecoder, GzDecoder};
51use openssl::{
52    asn1::{Asn1Time, Asn1TimeRef},
53    error::ErrorStack,
54    ssl::{SslConnector, SslMethod, SslVerifyMode},
55    x509::X509,
56};
57use url::Url;
58
59extern crate openssl;
60
61/// `ReadWrite` trait
62///
63/// This trait is implemented for types that implement the `Read` and `Write` traits.
64/// This is mainly used to make socket streams compatible with both [`TcpStream`] and [`openssl::ssl::SslStream`].
65pub trait ReadWrite: Read + Write + Debug {}
66impl<T: Read + Write + Debug> ReadWrite for T {}
67
68/// Error types
69///
70/// This module contains the error types for the http-timings crate.
71///
72/// The errors are defined using the `thiserror` crate.
73pub mod error {
74    use thiserror::Error;
75
76    use crate::ReadWrite;
77
78    #[derive(Error, Debug)]
79    /// Error types
80    ///
81    /// This enum contains the error types for the http-timings crate.
82    pub enum Error {
83        #[error("io error")]
84        /// IO error, derived from [`std::io::Error`]
85        Io(#[from] std::io::Error),
86        #[error("ssl error")]
87        /// SSL error, derived from [`openssl::error::ErrorStack`]
88        Ssl(#[from] openssl::error::ErrorStack),
89        #[error("ssl handshake error")]
90        /// SSL handshake error, derived from [`openssl::ssl::HandshakeError`]
91        SslHandshake(#[from] openssl::ssl::HandshakeError<Box<dyn ReadWrite + Send + Sync>>),
92        #[error("ssl certificate not found")]
93        /// SSL certificate not found
94        SslCertificateNotFound,
95        #[error("system time error")]
96        /// System time error, derived from [`std::time::SystemTimeError`]
97        SystemTime(#[from] std::time::SystemTimeError),
98    }
99}
100
101#[derive(Debug)]
102/// A pair of durations, one total and one relative
103///
104/// The total duration is the sum of the relative duration and the previous duration.
105pub struct DurationPair {
106    total: Duration,
107    relative: Duration,
108}
109
110impl DurationPair {
111    /// Returns the total duration
112    #[must_use]
113    pub fn total(&self) -> Duration {
114        self.total
115    }
116
117    /// Returns the relative duration
118    #[must_use]
119    pub fn relative(&self) -> Duration {
120        self.relative
121    }
122}
123
124#[derive(Debug)]
125/// The response timings for any given request. The response timings can be found
126/// [here](https://developer.chrome.com/docs/devtools/network/reference/?utm_source=devtools#timing-explanation).
127pub struct ResponseTimings {
128    /// DNS resolution time
129    pub dns: DurationPair,
130    /// TCP connection time
131    pub tcp: DurationPair,
132    /// TLS handshake time
133    pub tls: Option<DurationPair>,
134    /// HTTP request send time
135    pub http_send: DurationPair,
136    /// Time To First Byte
137    pub ttfb: DurationPair,
138    /// Content download time
139    pub content_download: DurationPair,
140}
141
142impl ResponseTimings {
143    fn new(
144        dns: Duration,
145        tcp: Duration,
146        tls: Option<Duration>,
147        http_send: Duration,
148        ttfb: Duration,
149        content_download: Duration,
150    ) -> Self {
151        let dns = DurationPair {
152            total: dns,
153            relative: dns,
154        };
155
156        let tcp = DurationPair {
157            total: dns.total + tcp,
158            relative: tcp,
159        };
160
161        let tls = tls.map(|tls| DurationPair {
162            total: tcp.total + tls,
163            relative: tls,
164        });
165
166        let http_send = DurationPair {
167            total: match &tls {
168                Some(tls) => tls.total + http_send,
169                None => tcp.total + http_send,
170            },
171            relative: http_send,
172        };
173
174        let ttfb = DurationPair {
175            total: http_send.total + ttfb,
176            relative: ttfb,
177        };
178
179        let content_download = DurationPair {
180            total: ttfb.total + content_download,
181            relative: content_download,
182        };
183
184        Self {
185            dns,
186            tcp,
187            tls,
188            http_send,
189            ttfb,
190            content_download,
191        }
192    }
193}
194
195#[derive(Debug)]
196/// Basic information about an SSL certificate
197pub struct CertificateInformation {
198    /// Issued at
199    pub issued_at: SystemTime,
200    /// Expires at
201    pub expires_at: SystemTime,
202    /// Subject
203    pub subject: String,
204    /// Is active
205    pub is_active: bool,
206}
207
208#[derive(Debug)]
209/// A body of a response
210pub struct Body {
211    inner: Vec<u8>,
212}
213
214impl Body {
215    /// Returns the body as a string
216    #[must_use]
217    pub fn string(&self) -> String {
218        String::from_utf8_lossy(&self.inner).into_owned()
219    }
220
221    /// Returns the body as bytes
222    #[must_use]
223    pub fn bytes(&self) -> &[u8] {
224        &self.inner
225    }
226
227    /// Converts the body into a vector of bytes
228    #[must_use]
229    pub fn into_bytes(self) -> Vec<u8> {
230        self.inner
231    }
232}
233
234#[derive(Debug)]
235/// The response from a given request
236pub struct Response {
237    /// The timings of the response
238    pub timings: ResponseTimings,
239    /// The certificate information
240    pub certificate_information: Option<CertificateInformation>,
241    /// The raw certificate
242    pub certificate: Option<X509>,
243    /// The status of the response
244    pub status: u16,
245    /// The body of the response
246    pub body: Body,
247}
248
249fn asn1_time_to_system_time(time: &Asn1TimeRef) -> Result<SystemTime, ErrorStack> {
250    let unix_time = Asn1Time::from_unix(0)?.diff(time)?;
251    Ok(SystemTime::UNIX_EPOCH
252        + Duration::from_secs((unix_time.days as u64) * 86400 + unix_time.secs as u64))
253}
254
255fn get_dns_timing(url: &Url) -> Result<(Duration, IntoIter<SocketAddr>), error::Error> {
256    let Some(domain) = url.host_str() else {
257        return Err(error::Error::Io(std::io::Error::new(
258            std::io::ErrorKind::InvalidInput,
259            "invalid url",
260        )));
261    };
262    let port = url.port().unwrap_or(match url.scheme() {
263        "http" => 80,
264        "https" => 443,
265        _ => {
266            return Err(error::Error::Io(std::io::Error::new(
267                std::io::ErrorKind::InvalidInput,
268                "invalid url scheme",
269            )));
270        }
271    });
272    let start = std::time::Instant::now();
273    match format!("{domain}:{port}").to_socket_addrs() {
274        Ok(addrs) => Ok((start.elapsed(), addrs)),
275        Err(e) => Err(error::Error::Io(e)),
276    }
277}
278
279fn get_tcp_timing(
280    addr: &SocketAddr,
281    timeout: Option<Duration>,
282) -> Result<(Duration, Box<dyn ReadWrite + Send + Sync>), error::Error> {
283    let now = std::time::Instant::now();
284    let stream = match TcpStream::connect_timeout(addr, timeout.unwrap_or(Duration::from_secs(5))) {
285        Ok(stream) => stream,
286        Err(e) => return Err(error::Error::Io(e)),
287    };
288    Ok((now.elapsed(), Box::new(stream)))
289}
290
291struct TlsTimingResponse {
292    timing: Duration,
293    stream: Box<dyn ReadWrite + Send + Sync>,
294    certificate_information: Option<CertificateInformation>,
295    certificate: Option<X509>,
296}
297
298fn get_tls_timing(
299    url: &Url,
300    stream: Box<dyn ReadWrite + Send + Sync>,
301) -> Result<TlsTimingResponse, error::Error> {
302    let now = std::time::Instant::now();
303    let connector = {
304        let mut context = match SslConnector::builder(SslMethod::tls()) {
305            Ok(context) => context,
306            Err(e) => return Err(error::Error::Ssl(e)),
307        };
308        context.set_verify(SslVerifyMode::NONE);
309        context.build()
310    };
311    let stream = match connector.connect(
312        match url.host_str() {
313            Some(host) => host,
314            None => {
315                return Err(error::Error::Io(std::io::Error::new(
316                    std::io::ErrorKind::InvalidInput,
317                    "invalid url host",
318                )));
319            }
320        },
321        stream,
322    ) {
323        Ok(stream) => stream,
324        Err(e) => return Err(error::Error::SslHandshake(e)),
325    };
326    let Some(raw_certificate) = stream.ssl().peer_certificate() else {
327        return Err(error::Error::SslCertificateNotFound);
328    };
329    let time_elapsed = now.elapsed();
330
331    let current_asn1_time =
332        Asn1Time::from_unix(match SystemTime::now().duration_since(UNIX_EPOCH) {
333            Ok(duration) => duration.as_secs() as i64,
334            Err(e) => return Err(error::Error::SystemTime(e)),
335        })?;
336    let certificate_information = CertificateInformation {
337        issued_at: asn1_time_to_system_time(raw_certificate.not_before())?,
338        expires_at: asn1_time_to_system_time(raw_certificate.not_after())?,
339        subject: raw_certificate
340            .subject_name()
341            .entries()
342            .map(|entry| entry.data().as_slice().to_ascii_lowercase())
343            .map(|entry| String::from_utf8_lossy(entry.as_slice()).into_owned())
344            .collect(),
345        is_active: raw_certificate.not_after() > current_asn1_time,
346    };
347
348    Ok(TlsTimingResponse {
349        timing: time_elapsed,
350        stream: Box::new(stream),
351        certificate_information: Some(certificate_information),
352        certificate: Some(raw_certificate),
353    })
354}
355
356fn get_http_send_timing(
357    url: &Url,
358    stream: &mut Box<dyn ReadWrite + Send + Sync>,
359) -> Result<Duration, error::Error> {
360    let now = std::time::Instant::now();
361    let url_string = match url.query() {
362        Some(query) => format!("{}?{query}", url.path()),
363        None => url.path().to_string(),
364    };
365    let request = format!(
366        "GET {} HTTP/1.0\r\nHost: {}\r\nAccept-Encoding: gzip, deflate, br\r\nUser-Agent: http-timings/{}\r\nConnection: keep-alive\r\nAccept: */*\r\n\r\n",
367        url_string,
368        match url.host_str() {
369            Some(host) => host,
370            None =>
371                return Err(error::Error::Io(std::io::Error::new(
372                    std::io::ErrorKind::InvalidInput,
373                    "invalid url host",
374                ))),
375        },
376        env!("CARGO_PKG_VERSION"),
377    );
378    if let Err(err) = stream.write_all(request.as_bytes()) {
379        return Err(error::Error::Io(err));
380    }
381    Ok(now.elapsed())
382}
383
384fn get_ttfb_timing(
385    stream: &mut Box<dyn ReadWrite + Send + Sync>,
386) -> Result<Duration, error::Error> {
387    let mut one_byte = vec![0_u8];
388    let now = std::time::Instant::now();
389    if let Err(err) = stream.read_exact(&mut one_byte) {
390        return Err(error::Error::Io(err));
391    }
392    Ok(now.elapsed())
393}
394
395fn get_content_download_timing(
396    stream: &mut Box<dyn ReadWrite + Send + Sync>,
397) -> Result<(Duration, u16, Body), error::Error> {
398    let mut reader = BufReader::new(stream);
399    let mut header_buf = String::new();
400    let now = std::time::Instant::now();
401    loop {
402        let bytes_read = match reader.read_line(&mut header_buf) {
403            Ok(bytes_read) => bytes_read,
404            Err(err) => return Err(error::Error::Io(err)),
405        };
406        if bytes_read == 2 {
407            break;
408        }
409    }
410    let headers = header_buf.split('\n');
411    let content_length = match headers
412        .clone()
413        .filter(|line| line.to_ascii_lowercase().starts_with("content-length"))
414        .collect::<Vec<_>>()
415        .first()
416    {
417        Some(content_length) => content_length.split(':').collect::<Vec<_>>()[1]
418            .trim()
419            .parse()
420            .unwrap_or(0),
421        None => 0,
422    };
423
424    let status = match headers
425        .clone()
426        .filter(|line| line.starts_with("TTP"))
427        .collect::<Vec<_>>()
428        .first()
429    {
430        Some(status) => status.split(' ').collect::<Vec<_>>()[1]
431            .parse::<u16>()
432            .unwrap_or(0),
433        None => {
434            return Err(error::Error::Io(std::io::Error::new(
435                std::io::ErrorKind::InvalidInput,
436                "invalid http status",
437            )));
438        }
439    };
440
441    let mut body_buf;
442    if content_length == 0 {
443        body_buf = vec![];
444        if let Err(err) = reader.read_to_end(&mut body_buf) {
445            return Err(error::Error::Io(err));
446        }
447    } else {
448        body_buf = vec![0_u8; content_length];
449        if let Err(err) = reader.read_exact(&mut body_buf) {
450            return Err(error::Error::Io(err));
451        }
452    }
453
454    let content_encoding = match headers
455        .filter(|line| line.to_ascii_lowercase().starts_with("content-encoding"))
456        .collect::<Vec<_>>()
457        .first()
458    {
459        Some(content_encoding) => content_encoding.split(':').collect::<Vec<_>>()[1].trim(),
460        None => "",
461    };
462
463    let body = match content_encoding {
464        "gzip" => {
465            let decoder = GzDecoder::new(&body_buf[..]);
466            let mut decode_reader = BufReader::new(decoder);
467            let mut buf = vec![];
468            let _ = decode_reader.read_to_end(&mut buf);
469            Body { inner: buf }
470        }
471        "deflate" => {
472            let mut decoder = DeflateDecoder::new(&body_buf[..]);
473            let mut buf = vec![];
474            if let Err(err) = decoder.read_to_end(&mut buf) {
475                return Err(error::Error::Io(err));
476            }
477            Body { inner: buf }
478        }
479        "br" => {
480            let mut decoder = brotli::Decompressor::new(&body_buf[..], 4096);
481            let mut buf = vec![];
482            if let Err(err) = decoder.read_to_end(&mut buf) {
483                return Err(error::Error::Io(err));
484            }
485            Body { inner: buf }
486        }
487        _ => Body { inner: body_buf },
488    };
489
490    Ok((now.elapsed(), status, body))
491}
492
493/// Measures the HTTP timings from the given URL
494///
495/// # Errors
496///
497/// This function will return an error if the URL is invalid or the URL is not reachable.
498/// It could also error under any scenario in the [`error::Error`] enum.
499pub fn from_url(url: &Url, timeout: Option<Duration>) -> Result<Response, error::Error> {
500    let (dns_timing, mut socket_addrs) = get_dns_timing(url)?;
501    let Some(url_ip) = socket_addrs.next() else {
502        return Err(error::Error::Io(std::io::Error::new(
503            std::io::ErrorKind::InvalidInput,
504            "invalid url ip",
505        )));
506    };
507    let (tcp_timing, mut stream) = get_tcp_timing(&url_ip, timeout)?;
508    let mut ssl_certificate = None;
509    let mut ssl_certificate_information = None;
510    let mut tls_timing = None;
511    if url.scheme() == "https" {
512        let timing_response = get_tls_timing(url, stream)?;
513        tls_timing = Some(timing_response.timing);
514        ssl_certificate = timing_response.certificate;
515        ssl_certificate_information = timing_response.certificate_information;
516        stream = timing_response.stream;
517    }
518    let http_send_timing = get_http_send_timing(url, &mut stream)?;
519    let ttfb_timing = get_ttfb_timing(&mut stream)?;
520    let (content_download_timing, status, body) = get_content_download_timing(&mut stream)?;
521
522    Ok(Response {
523        timings: ResponseTimings::new(
524            dns_timing,
525            tcp_timing,
526            tls_timing,
527            http_send_timing,
528            ttfb_timing,
529            content_download_timing,
530        ),
531        certificate_information: ssl_certificate_information,
532        certificate: ssl_certificate,
533        status,
534        body,
535    })
536}
537
538/// Given a string, it will be parsed as a URL and the HTTP timings will be measured
539///
540/// # Errors
541///
542/// This function will return an error if the URL is invalid or the URL is not reachable.
543/// It could also error under any scenario in the [`error::Error`] enum.
544pub fn from_string(url: &str, timeout: Option<Duration>) -> Result<Response, error::Error> {
545    let input = if !url.starts_with("http://") && !url.starts_with("https://") {
546        format!("http://{url}")
547    } else {
548        url.to_string()
549    };
550
551    let url = Url::parse(&input).map_err(|e| {
552        error::Error::Io(std::io::Error::new(
553            std::io::ErrorKind::InvalidInput,
554            format!("invalid url: {e}"),
555        ))
556    })?;
557    from_url(&url, timeout)
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    const TIMEOUT: Duration = Duration::from_secs(5);
564
565    #[test]
566    fn test_non_tls_connection() {
567        let url = "httpforever.com";
568        let result = from_string(url, Some(TIMEOUT));
569        assert!(result.is_ok());
570        let response = result.unwrap();
571        assert_eq!(response.status, 200);
572        assert!(response.body.string().contains("scotthelme.co.uk"));
573        assert!(response.timings.dns.total.as_secs() < 1);
574        assert!(response.timings.content_download.total.as_secs() < 5);
575    }
576
577    #[test]
578    fn test_popular_tls_connection() {
579        let url = "https://www.google.com";
580        let result = from_string(url, Some(TIMEOUT));
581        assert!(result.is_ok());
582        let response = result.unwrap();
583        assert_eq!(response.status, 200);
584        assert!(response.body.string().contains("Google Search"));
585        assert!(response.timings.dns.total.as_secs() < 1);
586        assert!(response.timings.content_download.total.as_secs() < 5);
587    }
588
589    #[test]
590    fn test_ip() {
591        let url = "1.1.1.1";
592        let result = from_string(url, Some(TIMEOUT));
593        assert!(result.is_ok());
594        let response = result.unwrap();
595        assert_eq!(response.status, 301);
596        assert!(!response.body.bytes().is_empty());
597        assert!(response.timings.dns.total.as_secs() < 1);
598        assert!(response.timings.content_download.total.as_secs() < 5);
599    }
600
601    #[test]
602    fn test_url_with_query() {
603        let url = "https://shouldideploy.today/api?tz=UTC&lang=en";
604        let result = from_string(url, Some(TIMEOUT));
605        assert!(result.is_ok());
606        let response = result.unwrap();
607        assert_eq!(response.status, 200);
608        assert!(response.body.string().contains("shouldideploy"));
609        assert!(response.timings.dns.total.as_secs() < 1);
610        assert!(response.timings.content_download.total.as_secs() < 5);
611    }
612}