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
11use 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
61pub trait ReadWrite: Read + Write + Debug {}
66impl<T: Read + Write + Debug> ReadWrite for T {}
67
68pub mod error {
74 use thiserror::Error;
75
76 use crate::ReadWrite;
77
78 #[derive(Error, Debug)]
79 pub enum Error {
83 #[error("io error")]
84 Io(#[from] std::io::Error),
86 #[error("ssl error")]
87 Ssl(#[from] openssl::error::ErrorStack),
89 #[error("ssl handshake error")]
90 SslHandshake(#[from] openssl::ssl::HandshakeError<Box<dyn ReadWrite + Send + Sync>>),
92 #[error("ssl certificate not found")]
93 SslCertificateNotFound,
95 #[error("system time error")]
96 SystemTime(#[from] std::time::SystemTimeError),
98 }
99}
100
101#[derive(Debug)]
102pub struct DurationPair {
106 total: Duration,
107 relative: Duration,
108}
109
110impl DurationPair {
111 #[must_use]
113 pub fn total(&self) -> Duration {
114 self.total
115 }
116
117 #[must_use]
119 pub fn relative(&self) -> Duration {
120 self.relative
121 }
122}
123
124#[derive(Debug)]
125pub struct ResponseTimings {
128 pub dns: DurationPair,
130 pub tcp: DurationPair,
132 pub tls: Option<DurationPair>,
134 pub http_send: DurationPair,
136 pub ttfb: DurationPair,
138 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)]
196pub struct CertificateInformation {
198 pub issued_at: SystemTime,
200 pub expires_at: SystemTime,
202 pub subject: String,
204 pub is_active: bool,
206}
207
208#[derive(Debug)]
209pub struct Body {
211 inner: Vec<u8>,
212}
213
214impl Body {
215 #[must_use]
217 pub fn string(&self) -> String {
218 String::from_utf8_lossy(&self.inner).into_owned()
219 }
220
221 #[must_use]
223 pub fn bytes(&self) -> &[u8] {
224 &self.inner
225 }
226
227 #[must_use]
229 pub fn into_bytes(self) -> Vec<u8> {
230 self.inner
231 }
232}
233
234#[derive(Debug)]
235pub struct Response {
237 pub timings: ResponseTimings,
239 pub certificate_information: Option<CertificateInformation>,
241 pub certificate: Option<X509>,
243 pub status: u16,
245 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
493pub 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
538pub 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}