1#![warn(rust_2018_idioms)]
5#![warn(missing_docs, missing_debug_implementations)]
6#![warn(anonymous_parameters, bare_trait_objects, unreachable_pub)]
7#![deny(unused)]
8#![deny(unused_variables)]
9#![forbid(unsafe_code)]
10use async_std::io;
13use async_std::net::TcpStream;
14use async_tls::TlsConnector;
15use futures::io::AsyncWriteExt;
16use httparse::Header;
17use httparse::Response;
18use nom::bytes::complete::tag;
19use nom::bytes::complete::take_until;
20use nom::error::ErrorKind;
21use nom::sequence::tuple;
22use nom::IResult;
23use rustls::ClientConfig;
24use std::boxed::Box;
25use std::convert::TryFrom;
26use std::error::Error;
27use std::io::Cursor;
28use std::net::SocketAddr;
29use std::net::ToSocketAddrs;
30use std::path::Path;
31use std::path::PathBuf;
32use std::str::from_utf8;
33use std::sync::Arc;
34use std::time::Duration;
35use structopt::StructOpt;
36use url::Host;
37use url::ParseError;
38use url::Url;
39use x11_clipboard::Clipboard;
40
41#[macro_use]
42extern crate log;
43
44#[allow(trivial_casts)]
51
52const TITLE_TAG_OPEN: &str = "<title>";
53const TITLE_TAG_CLOSE: &str = "</title>";
54
55#[derive(Debug, StructOpt)]
60pub struct Options {
61 #[structopt(short = "u", long = "url")]
64 pub url: Option<String>,
65
66 #[structopt(short = "c", long = "clipboard")]
69 pub clipboard: bool,
70
71 #[structopt(short = "h", long = "host")]
73 pub host: Option<String>,
74
75 #[structopt(short = "p", long = "port", default_value = "443")]
77 pub port: u16,
78
79 #[structopt(short = "s", long = "scheme", default_value = "https")]
81 pub scheme: String,
82
83 #[structopt(short = "t", long = "path", default_value = "/")]
87 pub path: String,
88
89 #[structopt(short = "q", long = "query")]
92 pub query: Option<String>,
93
94 #[structopt(short = "f", long = "fragment")]
96 pub fragment: Option<String>,
97
98 #[structopt(short = "d", long = "domain")]
100 pub domain: Option<String>,
101
102 #[structopt(short = "", long = "cafile", parse(from_os_str))]
105 pub cafile: Option<PathBuf>,
106
107 #[structopt(short = "r", long = "run")]
110 pub run: bool,
111}
112
113pub async fn connector(cafile: &Option<PathBuf>) -> Result<TlsConnector, Box<dyn Error>> {
115 if let Some(cafile) = cafile {
116 connector_for_ca_file(cafile).await.map_err(|ioerr| {
117 let e: Box<dyn Error> = Box::new(ioerr);
118 e
119 })
120 } else {
121 Ok(TlsConnector::default())
122 }
123}
124
125async fn connector_for_ca_file(cafile: &Path) -> Result<TlsConnector, io::Error> {
126 let mut config = ClientConfig::new();
127 let file = async_std::fs::read(cafile).await?;
128 let mut pem = Cursor::new(file);
129 config
130 .root_store
131 .add_pem_file(&mut pem)
132 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert"))?;
133 Ok(TlsConnector::from(Arc::new(config)))
134}
135
136pub fn page_title_from_html(content: &[u8]) -> Result<Option<String>, Box<dyn Error>> {
173 let content: &str = match from_utf8(&content) {
174 Ok(v) => v,
175 Err(e) => {
176 let e: Box<dyn Error> = From::from(format!("Invalid UTF-8 sequence: {}", e));
177 return Err(e);
178 }
179 };
180
181 let has_tag: Result<(&str, &str), nom::Err<(&str, ErrorKind)>> = until_open_title_tag(content);
182 if let Ok((content, _before_tag)) = has_tag {
183 let parsed: IResult<&str, (_, _, &str), (&str, ErrorKind)> = tuple((
184 take_until(TITLE_TAG_OPEN),
185 tag(TITLE_TAG_OPEN),
186 take_until(TITLE_TAG_CLOSE),
187 ))(content);
188 let (_, (_, _, title)) = match parsed {
189 Ok(r) => r,
190 Err(_) => {
191 let e: Box<dyn Error> = From::from("parsing error");
192 return Err(e);
193 }
194 };
195
196 return Ok(Some(String::from(title.trim())));
197 }
198 Ok(None)
199}
200
201fn until_open_title_tag(s: &str) -> IResult<&str, &str> {
202 take_until(TITLE_TAG_OPEN)(s)
203}
204
205pub fn has_redirect(
210 response: &Response<'_, '_>,
211 origin: &Url,
212) -> Result<Option<Url>, Box<dyn Error>> {
213 match &response.code {
214 None => {
215 let e: Box<dyn Error> =
216 From::from("HTTP response parsing error: could not find StatusCode");
217 Err(e)
218 }
219 Some(c @ 300..=308) => {
220 for Header { name, value } in response.headers.iter() {
221 if name == &"Location" {
222 let dest: &str = from_utf8(&value).unwrap();
223 let dest: Url = match Url::parse(dest) {
224 Ok(url) => url,
225 Err(ParseError::RelativeUrlWithoutBase) => origin
226 .join(dest)
227 .expect("unparseable relative redirect destination"),
228 Err(_) => {
229 unreachable!();
231 }
232 };
233 debug!("HTTP redirect: {} -> {}", c, dest);
234 return Ok(Some(dest));
235 }
236 }
237 let e: Box<dyn Error> = From::from(format!("HTTP redirect without location: {}", c));
238 Err(e)
239 }
240 Some(c) => {
241 debug!("HTTP StatusCode: {}", c);
242 Ok(None)
243 }
244 }
245}
246
247#[derive(Debug)]
252pub enum Jump {
253 Next(Url),
255 Landing(Vec<u8>),
257}
258
259pub async fn page_content(connector: &TlsConnector, parts: &Parts) -> Result<Jump, Box<dyn Error>> {
274 let socket_addr: &SocketAddr = &parts.addr;
275 let domain: &str = &parts.domain;
276
277 let tcp_stream: TcpStream = match TcpStream::connect(&socket_addr).await {
279 Ok(t) => t,
280 Err(e) => {
281 let e: Box<dyn Error> = From::from(format!("TCPStream error: {}", e));
282 return Err(e);
283 }
284 };
285
286 let mut tls_stream = match connector.connect(&domain, tcp_stream).await {
291 Ok(ts) => ts,
292 Err(e) => {
293 let e: Box<dyn Error> = From::from(format!("TLSStream error: {}", e));
294 return Err(e);
295 }
296 };
297
298 tls_stream
300 .write_all(parts.http_request().as_bytes())
301 .await?;
302
303 let mut response_content: Vec<u8> = Vec::with_capacity(1024);
305 io::copy(&mut tls_stream, &mut response_content).await?;
306
307 let mut headers: [Header<'_>; 32] = [httparse::EMPTY_HEADER; 32];
308 let mut response: Response<'_, '_> = Response::new(&mut headers);
309 response.parse(&response_content)?;
310
311 match has_redirect(&response, &parts.url)? {
312 Some(url) => Ok(Jump::Next(url)),
313 None => Ok(Jump::Landing(response_content)),
314 }
315}
316
317fn clipboard_content() -> Result<String, Box<dyn Error>> {
318 let clipboard = Clipboard::new().unwrap();
319 let clipboard_content = clipboard.load(
320 clipboard.setter.atoms.clipboard,
321 clipboard.setter.atoms.utf8_string,
322 clipboard.setter.atoms.property,
323 Duration::from_secs(3),
324 )?;
325 let clipboard_content = String::from_utf8(clipboard_content).expect("UTF-8 content");
326 debug!("clipboard content: {}", &clipboard_content);
327 Ok(clipboard_content)
328}
329
330#[derive(Clone, Debug)]
343pub struct Parts {
344 pub url: Url,
350 pub host: Host<String>,
352 pub port: u16,
354 pub addr: SocketAddr,
356 pub domain: String,
358 pub path: String,
363 pub query: String,
365 pub fragment: String,
375}
376
377impl Parts {
378 pub fn http_request(&self) -> String {
383 let query = if self.query != "" && !self.query.starts_with('?') {
384 format!("?{}", self.query)
385 } else {
386 self.query.clone()
387 };
388 let fragment = if self.fragment != "" && !self.fragment.starts_with('#') {
389 format!("#{}", self.fragment)
390 } else {
391 self.fragment.clone()
392 };
393 let http_request: String = format!(
394 "GET {}{}{} HTTP/1.0\r\nHost: {}\r\n\r\n",
395 self.path, query, fragment, self.domain,
396 );
397 debug!("HTTP request: {}", &http_request);
398 http_request
399 }
400}
401
402impl TryFrom<&Options> for Parts {
403 type Error = Box<dyn Error>;
405
406 fn try_from(options: &Options) -> Result<Self, Self::Error> {
407 let mutex_opts: [bool; 3] = [
409 options.clipboard,
410 options.url.is_some(),
411 options.host.is_some(),
412 ];
413 match mutex_opts {
414 [true, false, false] | [false, true, false] | [false, false, true] => {}
415 [false, false, false]
416 | [true, true, false]
417 | [true, false, true]
418 | [false, true, true]
419 | [true, true, true] => {
420 let e: Box<dyn Error> =
421 From::from("use one and only one of --clipboard, --url or --host");
422 return Err(e);
423 }
424 }
425
426 let url: Url = if options.clipboard {
427 Url::parse(&clipboard_content().expect("clipboard is not readable"))
428 .expect("Clipboard is not a URL")
429 } else if let Some(u) = &options.url {
430 Url::parse(&u).expect("Unparseable URL")
431 } else if let Some(h) = &options.host {
432 Url::parse(&format!("{}://{}", options.scheme, h)).expect("Unparseable scheme and host")
433 } else {
434 unreachable!("use only one of --clipboard, --url or --host")
435 };
436
437 let host: Host<String> = if let Some(h) = &options.host {
438 Host::parse(&h).expect("Unparseable Host")
439 } else {
440 url.host().expect("URL without host").to_owned()
441 };
442 debug!("host name: {}", &host);
443
444 let port: u16 = match options.port {
445 0 => match url.scheme() {
446 "https" => 443,
447 "http" => {
448 let e: Box<dyn Error> =
449 From::from("HTTP standard ports is not yet suported".to_string());
450 return Err(e);
451 }
452 s => {
453 let e: Box<dyn Error> = From::from(format!(
454 "Only HTTP(S) standard ports are suported. {} given",
455 s
456 ));
457 return Err(e);
458 }
459 },
460 p => p,
461 };
462 debug!("port number: {}", &port);
463
464 let addr: SocketAddr = (host.to_string().as_str(), options.port)
478 .to_socket_addrs()?
479 .next()
480 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
481 debug!("socket address: {}", &addr);
482
483 let domain: String = if let Some(d) = &options.domain {
485 d.to_owned()
486 } else {
487 host.to_string()
488 };
489 debug!("domain {}", &domain);
490
491 let path: String = match &options.host {
492 None => (&url.path()).to_string(),
493 _ => options.path.to_string(),
494 };
495
496 let query: String = match &options.query {
497 Some(q) => q.to_owned(),
498 None => url.query().unwrap_or("").to_string(),
499 };
500
501 let fragment: String = match &options.fragment {
502 Some(f) => f.to_owned(),
503 None => url.fragment().unwrap_or("").to_string(),
504 };
505
506 Ok(Parts {
507 url,
508 host,
509 port,
510 addr,
511 query,
512 fragment,
513 domain,
514 path,
515 })
516 }
517}
518
519impl TryFrom<&str> for Parts {
520 type Error = Box<dyn Error>;
521
522 fn try_from(url: &str) -> Result<Self, Self::Error> {
523 let url: Url = Url::parse(&url).expect("Unparseable URL");
524 Self::try_from(&url)
525 }
526}
527
528impl TryFrom<&Url> for Parts {
529 type Error = Box<dyn Error>;
530
531 fn try_from(url: &Url) -> Result<Self, Self::Error> {
532 let host: Host<String> = url.host().expect("URL without host").to_owned();
533 let port: u16 = match url.scheme() {
534 "https" => 443,
535 "http" => 80,
536 s => {
537 let e: Box<dyn Error> = From::from(format!(
538 "Only HTTP(S) standard ports are suported. {} given",
539 s
540 ));
541 return Err(e);
542 }
543 };
544 let addr: SocketAddr = (host.to_string().as_str(), port)
545 .to_socket_addrs()?
546 .next()
547 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
548 let domain: String = host.to_string();
550 let path: String = (&url.path()).to_string();
551 let query: String = url.query().unwrap_or("").to_string();
552 let fragment: String = url.fragment().unwrap_or("").to_string();
553
554 Ok(Parts {
555 url: url.clone(),
556 host,
557 port,
558 addr,
559 query,
560 fragment,
561 domain,
562 path,
563 })
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use async_std::task;
571 use std::fs;
572 use std::time::Instant;
573
574 #[allow(dead_code)]
575 fn init() {
576 let _ = env_logger::builder().is_test(true).try_init();
577 }
578
579 #[test]
580 fn test_connector_passes_without_cafile() -> Result<(), String> {
581 match task::block_on(async { connector(&None).await }) {
582 Ok(_) => Ok(()),
583 Err(_) => Err(String::from("connector should use the system CA files")),
584 }
585 }
586
587 #[test]
588 fn test_connector_fails_with_nonexisting_cafile() -> Result<(), String> {
589 let cafile: &Option<PathBuf> = &Some(PathBuf::from("/nope"));
590 match task::block_on(async { connector(cafile).await }) {
591 Ok(_) => Err(String::from(
592 "connector should fail when the CA file does not exist",
593 )),
594 Err(_) => Ok(()),
595 }
596 }
597
598 #[test]
599 #[should_panic(expected = "No such file or directory")]
600 fn test_connector_fails_with_nonexisting_cafile_with_failure_message() {
601 let cafile: &Option<PathBuf> = &Some(PathBuf::from("/nope"));
602 task::block_on(async { connector(cafile).await }).unwrap();
603 }
604
605 #[test]
606 fn test_connector_for_ca_file_passes() -> Result<(), String> {
607 let cafile: &Path =
610 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/doc_rust-lang_org.crt");
611 match task::block_on(async { connector_for_ca_file(cafile).await }) {
612 Ok(_) => Ok(()),
613 Err(_) => Err(String::from("could not load the test PEM certificate")),
614 }
615 }
616
617 #[test]
618 fn test_page_title_from_html_passes_with_valid_html() -> Result<(), String> {
619 let page: &Path =
620 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/home_with_title.html");
621 match page_title_from_html(
622 &fs::read(page).map_err(|_| String::from("could not open HTML test page"))?,
623 ) {
624 Ok(Some(title)) if title == String::from("Rust Programming Language") => Ok(()),
625 Ok(Some(title)) => Err(format!("unexpected title: \"{}\"", title)),
626 Ok(None) => Err(From::from("could not find a title in the page")),
627
628 _ => Err(String::from(
629 "could not extract the title from the HTML page content",
630 )),
631 }
632 }
633
634 #[test]
635 fn test_page_title_from_html_passes_with_valid_html_without_title() -> Result<(), String> {
636 let page: &Path =
637 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/home_without_title.html");
638 match page_title_from_html(
639 &fs::read(page).map_err(|_| String::from("could not open HTML test page"))?,
640 ) {
641 Ok(Some(title)) => Err(format!("unexpected title: \"{}\"", title)),
642 Ok(None) => Ok(()),
643 Err(e) => Err(format!("unexpected error: {}", e)),
644 }
645 }
646
647 #[test]
648 fn test_page_title_from_html_fails_with_non_utf8_characters() -> Result<(), String> {
649 match page_title_from_html(&vec![0, 159, 146, 150]) {
650 Ok(_) => Err(format!(
651 "unexpected success extracting the title from non UTF8 characters"
652 )),
653 Err(e)
654 if (*e).to_string()
655 == "Invalid UTF-8 sequence: invalid utf-8 sequence of 1 bytes from index 1" =>
656 {
657 Ok(())
658 }
659 Err(e) => Err(format!("unexpected error: {}", e)),
660 }
661 }
662
663 #[test]
664 #[ignore]
665 fn test_page_title_from_html_with_attributes() -> Result<(), String> {
666 unimplemented!()
667 }
668
669 #[test]
670 fn test_has_redirect() -> Result<(), String> {
671 let origin: Url = Url::parse("https://doc.rust-lang.org/std")
672 .map_err(|e| format!("could not parse origin URL: {}", e))?;
673 let destination: Url = Url::parse("https://doc.rust-lang.org/stable/std/")
674 .map_err(|e| format!("could not parse destination URL: {}", e))?;
675 let mut headers: Vec<Header<'_>> = Vec::new();
676 let redirect_header = Header {
677 name: "Location",
678 value: destination.as_str().as_bytes(),
679 };
680 headers.push(redirect_header);
681 let mut response = Response::new(&mut headers);
682 response.code = Some(301);
683 match has_redirect(&response, &origin) {
684 Ok(Some(u)) if u == destination => Ok(()),
685 Ok(Some(u)) => Err(format!("unexpected redirect location: \"{}\"", u)),
686 Ok(None) => Err(format!("could not find HTTP redirect location")),
687 Err(e) => Err(format!("could not parse HTTP Response: {}", e)),
688 }
689 }
690
691 #[test]
692 fn test_has_no_redirect() -> Result<(), String> {
693 let origin: Url = Url::parse("https://doc.rust-lang.org/std")
694 .map_err(|e| format!("could not parse origin URL: {}", e))?;
695 let mut headers: Vec<Header<'_>> = Vec::new();
696 let mut response = Response::new(&mut headers);
697 response.code = Some(200);
698 match has_redirect(&response, &origin) {
699 Ok(None) => Ok(()),
700 Ok(Some(u)) => Err(format!("unexpected redirect location: \"{}\"", u)),
701 Err(e) => Err(format!("could not parse HTTP Response: {}", e)),
702 }
703 }
704
705 #[test]
706 fn test_has_redirect_fails_without_status_code() -> Result<(), String> {
707 let origin: Url = Url::parse("https://doc.rust-lang.org/std")
708 .map_err(|e| format!("could not parse origin URL: {}", e))?;
709 let mut headers: Vec<Header<'_>> = Vec::new();
710 let mut response = Response::new(&mut headers);
711 response.code = None;
712 match has_redirect(&response, &origin) {
713 Ok(_) => Err(From::from("unexpected success in redirect detection")),
714 Err(e)
715 if (*e).to_string() == "HTTP response parsing error: could not find StatusCode" =>
716 {
717 Ok(())
718 }
719 Err(e) => Err(format!("unexpected error message: {}", e)),
720 }
721 }
722
723 #[test]
724 #[ignore]
725 fn test_has_redirect_fails_with_unparseable_redirect_location() -> Result<(), String> {
726 let origin: Url = Url::parse("https://doc.rust-lang.org/std")
727 .map_err(|e| format!("could not parse origin URL: {}", e))?;
728 let mut headers: Vec<Header<'_>> = Vec::new();
729 let redirect_header = Header {
730 name: "Location",
731 value: "unreachable".as_bytes(),
732 };
733 headers.push(redirect_header);
734 let mut response = Response::new(&mut headers);
735 response.code = Some(301);
736 match has_redirect(&response, &origin) {
737 Ok(l) => Err(format!("unexpected success in redirect detection: {:?}", l)),
738 Err(e)
739 if (*e).to_string() == "HTTP response parsing error: could not find StatusCode" =>
740 {
741 Ok(())
742 }
743 Err(e) => Err(format!("unexpected error message: {}", e)),
744 }
745 }
746
747 #[test]
748 fn test_has_redirect_fails_without_redirect_location() -> Result<(), String> {
749 let origin: Url = Url::parse("https://doc.rust-lang.org/std")
750 .map_err(|e| format!("could not parse origin URL: {}", e))?;
751 let mut headers: Vec<Header<'_>> = Vec::new();
752 let mut response = Response::new(&mut headers);
753 response.code = Some(301);
754 match has_redirect(&response, &origin) {
755 Ok(l) => Err(format!("unexpected success in redirect detection: {:?}", l)),
756 Err(e) if (*e).to_string() == "HTTP redirect without location: 301" => Ok(()),
757 Err(e) => Err(format!("unexpected error message: {}", e)),
758 }
759 }
760
761 #[test]
762 fn test_has_redirect_passes_with_a_relative_redirect() -> Result<(), String> {
763 let origin: Url = Url::parse("https://doc.rust-lang.org/stable")
764 .map_err(|e| format!("could not parse origin URL: {}", e))?;
765 let mut headers: Vec<Header<'_>> = Vec::new();
766 let redirect_header = Header {
767 name: "Location",
768 value: "/stable/std/".as_bytes(),
769 };
770 headers.push(redirect_header);
771 let mut response = Response::new(&mut headers);
772 response.code = Some(301);
773 match has_redirect(&response, &origin) {
774 Ok(None) => Err(From::from("missing redirect detection")),
775 Ok(Some(l)) if l.to_string() == "https://doc.rust-lang.org/stable/std/" => Ok(()),
776 Ok(Some(l)) => Err(format!("unexpected redirect location: {}", l.to_string())),
777 Err(e) => Err(format!("could not detect redirection: {}", e)),
778 }
779 }
780
781 #[cfg(feature = "live-tests")]
782 mod livetests {
783 use super::*;
784 use std::net::IpAddr;
785 use std::net::Ipv4Addr;
786
787 #[test]
788 fn test_page_content_fails_with_http_only_url() -> Result<(), String> {
789 let connector: TlsConnector = TlsConnector::default();
790 let url: Url = Url::parse("http://example.org")
791 .map_err(|e| format!("could not parse URL: {}", e))?;
792 let parts: Parts = Parts::try_from(&url)
793 .map_err(|e| format!("could not parse parts from URL: {}", e))?;
794 match task::block_on(async { page_content(&connector, &parts).await }) {
795 Ok(_) => Err(format!("unexpected OK connection: HTTP should fail")),
796 Err(e) if (*e).to_string() == "TLSStream error: received corrupt message" => Ok(()),
797 Err(e) => Err(format!("unexpected error message: {}", e)),
798 }
799 }
800
801 #[test]
802 fn test_page_content_fails_with_broken_address() -> Result<(), String> {
803 let connector: TlsConnector = TlsConnector::default();
804 let parts: Parts = Parts {
805 url: Url::parse("http://example.org").unwrap(),
806 host: Host::parse("example.org").unwrap(),
807 port: 443,
808 addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
809 domain: String::new(),
810 path: String::new(),
811 query: String::new(),
812 fragment: String::new(),
813 };
814 match task::block_on(async { page_content(&connector, &parts).await }) {
815 Ok(_) => Err(format!("unexpected OK connection: HTTP should fail")),
816 Err(e)
817 if (*e).to_string() == "TCPStream error: Connection refused (os error 111)" =>
818 {
819 Ok(())
820 }
821 Err(e) => Err(format!("unexpected error message: {}", e)),
822 }
823 }
824
825 #[test]
826 fn test_page_content_from_url_with_jump_next() -> Result<(), String> {
827 let connector: TlsConnector = TlsConnector::default();
828 let url: Url = Url::parse("https://wikipedia.org")
829 .map_err(|e| format!("could not parse URL: {}", e))?;
830 let next: Url = Url::parse("https://www.wikipedia.org")
831 .map_err(|e| format!("could not parse URL: {}", e))?;
832 let parts: Parts = Parts::try_from(&url)
833 .map_err(|e| format!("could not parse parts from URL: {}", e))?;
834 match task::block_on(async { page_content(&connector, &parts).await }) {
835 Ok(Jump::Next(n)) if n == next => Ok(()),
836 Ok(Jump::Next(n)) => Err(format!("unexpected Jump::Next URL: {}", n)),
837 Ok(Jump::Landing(_)) => Err(format!("unexpected Jump::Landing")),
838 Err(e) => Err(format!("could not run live test: {}", e)),
839 }
840 }
841
842 #[test]
843 fn test_page_content_from_url_with_jump_landing() -> Result<(), String> {
844 let connector: TlsConnector = TlsConnector::default();
845 let url: Url = Url::parse("https://example.org")
846 .map_err(|e| format!("could not parse URL: {}", e))?;
847 let parts: Parts = Parts::try_from(&url)
848 .map_err(|e| format!("could not parse parts from URL: {}", e))?;
849 let http_ok_header: &str = "HTTP/1.0 200 OK";
850 match task::block_on(async { page_content(&connector, &parts).await }) {
851 Ok(Jump::Landing(content))
852 if from_utf8(&content[0..http_ok_header.len()]) == Ok(http_ok_header) =>
853 {
854 Ok(())
855 }
856 Ok(Jump::Landing(content)) => Err(format!(
857 "unexpected HTTP header: {:?}",
858 from_utf8(&content[0..http_ok_header.len()])
859 )),
860 Ok(Jump::Next(n)) => Err(format!("unexpected Jump::Next: {}", n)),
861 Err(e) => Err(format!("could not run live test: {}", e)),
862 }
863 }
864
865 mod parts {
866 use super::*;
867
868 #[test]
869 fn test_parts_try_from_options_fails_when_empty() -> Result<(), String> {
870 let options: Options = Options {
871 url: None, clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
883 match Parts::try_from(&options) {
884 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
885 Err(e)
886 if (*e).to_string()
887 == "use one and only one of --clipboard, --url or --host" =>
888 {
889 Ok(())
890 }
891 Err(e) => Err(format!("could not create Options: {}", e)),
892 }
893 }
894
895 #[test]
896 fn test_parts_try_from_options_fails_with_both_url_and_host() -> Result<(), String> {
897 let options: Options = Options {
898 url: Some("url".to_string()), clipboard: false, host: Some("host".to_string()), port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
910 match Parts::try_from(&options) {
911 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
912 Err(e)
913 if (*e).to_string()
914 == "use one and only one of --clipboard, --url or --host" =>
915 {
916 Ok(())
917 }
918 Err(e) => Err(format!("could not create Options: {}", e)),
919 }
920 }
921
922 #[test]
923 fn test_parts_try_from_options_fails_with_both_clipboard_and_host() -> Result<(), String>
924 {
925 let options: Options = Options {
926 url: None, clipboard: true, host: Some("host".to_string()), port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
938 match Parts::try_from(&options) {
939 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
940 Err(e)
941 if (*e).to_string()
942 == "use one and only one of --clipboard, --url or --host" =>
943 {
944 Ok(())
945 }
946 Err(e) => Err(format!("could not create Options: {}", e)),
947 }
948 }
949
950 #[test]
951 fn test_parts_try_from_options_fails_with_both_clipboard_and_url() -> Result<(), String>
952 {
953 let options: Options = Options {
954 url: Some("url".to_string()), clipboard: true, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
966 match Parts::try_from(&options) {
967 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
968 Err(e)
969 if (*e).to_string()
970 == "use one and only one of --clipboard, --url or --host" =>
971 {
972 Ok(())
973 }
974 Err(e) => Err(format!("could not create Options: {}", e)),
975 }
976 }
977
978 #[test]
979 fn test_parts_try_from_options_fails_with_both_clipboard_and_host_and_url()
980 -> Result<(), String> {
981 let options: Options = Options {
982 url: Some("url".to_string()), clipboard: true, host: Some("host".to_string()), port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
994 match Parts::try_from(&options) {
995 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
996 Err(e)
997 if (*e).to_string()
998 == "use one and only one of --clipboard, --url or --host" =>
999 {
1000 Ok(())
1001 }
1002 Err(e) => Err(format!("could not create Options: {}", e)),
1003 }
1004 }
1005
1006 #[test]
1007 fn test_parts_try_from_options_fails_with_non_existing_url() -> Result<(), String> {
1008 let options: Options = Options {
1009 url: Some("https://unresolvable.example.org".to_string()), clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1021 match Parts::try_from(&options) {
1022 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1023 Err(e)
1024 if e.to_string()
1025 == "failed to lookup address information: Name or service not known" =>
1026 {
1027 Ok(())
1028 }
1029 Err(e) => Err(format!(
1030 "unexpected error creating Parts from a URL: {}",
1031 e.to_string()
1032 )),
1033 }
1034 }
1035
1036 #[test]
1037 fn test_parts_try_from_options_fails_with_non_supported_scheme() -> Result<(), String> {
1038 let options: Options = Options {
1039 url: Some("news://example.org".to_string()), clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1051 match Parts::try_from(&options) {
1052 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1053 Err(e)
1054 if e.to_string()
1055 == "Only HTTP(S) standard ports are suported. news given" =>
1056 {
1057 Ok(())
1058 }
1059 Err(e) => Err(format!(
1060 "unexpected error creating Parts from a URL: {}",
1061 e.to_string()
1062 )),
1063 }
1064 }
1065
1066 fn validate_parts_and_url(parts: Parts, url: Option<String>) -> Result<(), String> {
1067 if let Some(url) = url {
1068 if parts.url != Url::parse(&url).unwrap() {
1069 return Err(format!(
1070 "could not create Options with the correct URL, got: {}",
1071 parts.url
1072 ));
1073 }
1074 }
1075 if parts.host != Host::parse("example.org").unwrap() {
1076 return Err(format!(
1077 "could not create Options with the correct Host, got: {}",
1078 parts.host
1079 ));
1080 }
1081 if parts.port != 443 {
1082 return Err(format!(
1083 "could not create Options with the correct port, got: {}",
1084 parts.port
1085 ));
1086 }
1087 if parts.path != "/news/today.html".to_string() {
1088 return Err(format!(
1089 "could not create Options with the correct URL path, got: {}",
1090 parts.path
1091 ));
1092 }
1093 if parts.query != "tag=top".to_string() {
1094 return Err(format!(
1095 "could not create Options with the correct query, got: {}",
1096 parts.query
1097 ));
1098 }
1099 if parts.fragment != "headline".to_string() {
1100 return Err(format!(
1101 "could not create Options with the correct fragment, got: {}",
1102 parts.fragment
1103 ));
1104 }
1105 if parts.domain != "example.org".to_string() {
1106 return Err(format!(
1107 "could not create Options with the correct domain, got: {}",
1108 parts.domain
1109 ));
1110 }
1111
1112 Ok(())
1113 }
1114
1115 #[test]
1116 fn test_parts_try_from_options_passes_with_existing_url() -> Result<(), String> {
1117 let url: String =
1118 "https://example.org/news/today.html?tag=top#headline".to_string();
1119 let options: Options = Options {
1120 url: Some(url.clone()), clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1132 match Parts::try_from(&options) {
1133 Ok(parts) => validate_parts_and_url(parts, options.url),
1134 Err(e) => Err(format!("could not create Options: {}", e)),
1135 }
1136 }
1137
1138 #[test]
1139 fn test_parts_try_from_options_fails_with_http_url() -> Result<(), String> {
1140 let url: String = "http://example.org".to_string();
1141 let options: Options = Options {
1142 url: Some(url.clone()), clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1154 match Parts::try_from(&options) {
1155 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1156 Err(e) if e.to_string() == "HTTP standard ports is not yet suported" => Ok(()),
1157 Err(e) => Err(format!(
1158 "unexpected error creating Parts from a URL: {}",
1159 e.to_string()
1160 )),
1161 }
1162 }
1163
1164 #[test]
1165 fn test_parts_try_from_options_passes_ignoring_non_https_port_when_given()
1166 -> Result<(), String> {
1167 let url: String = "http://example.org".to_string();
1168 let options: Options = Options {
1169 url: Some(url.clone()), clipboard: false, host: None, port: 8080u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1181 match Parts::try_from(&options) {
1182 Ok(_) => Ok(()),
1183 Err(e) => Err(format!(
1184 "could not create Parts from a URL: {}",
1185 e.to_string()
1186 )),
1187 }
1188 }
1189
1190 #[test]
1191 fn test_parts_try_from_options_passes_with_another_domain() -> Result<(), String> {
1192 let url: String = "https://example.com".to_string();
1193 let options: Options = Options {
1194 url: Some(url.clone()), clipboard: false, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: Some("unresolvable".to_string()), cafile: None, run: false, };
1206 match Parts::try_from(&options) {
1207 Ok(_) => Ok(()),
1208 Err(e) => Err(format!(
1209 "could not create Parts from a URL: {}",
1210 e.to_string()
1211 )),
1212 }
1213 }
1214
1215 #[test]
1216 fn test_parts_try_from_options_passes_with_existing_clipboard() -> Result<(), String> {
1217 let url: String =
1218 "https://example.org/news/today.html?tag=top#headline".to_string();
1219 let clipboard = Clipboard::new().unwrap();
1220 let atom_clipboard = clipboard.setter.atoms.clipboard;
1221 let atom_utf8string = clipboard.setter.atoms.utf8_string;
1222 clipboard
1223 .store(atom_clipboard, atom_utf8string, url.as_bytes())
1224 .map_err(|e| format!("could not store a value in the clipboard: {}", e))?;
1225
1226 let options: Options = Options {
1227 url: None, clipboard: true, host: None, port: 0u16, scheme: "".to_string(), path: "".to_string(), query: None, fragment: None, domain: None, cafile: None, run: false, };
1239 match Parts::try_from(&options) {
1240 Ok(parts) => validate_parts_and_url(parts, options.url),
1241 Err(e) => Err(format!("could not create Options: {}", e)),
1242 }
1243 }
1244
1245 #[test]
1246 fn test_parts_try_from_options_passes_with_existing_host() -> Result<(), String> {
1247 let options: Options = Options {
1248 url: None, clipboard: false, host: Some("example.org".to_string()), port: 0u16, scheme: "https".to_string(), path: "/news/today.html".to_string(), query: Some("tag=top".to_string()), fragment: Some("headline".to_string()), domain: None, cafile: None, run: false, };
1260 match Parts::try_from(&options) {
1261 Ok(parts) => validate_parts_and_url(parts, options.url),
1262 Err(e) => Err(format!("could not create Options: {}", e)),
1263 }
1264 }
1265
1266 #[test]
1267 fn test_parts_try_from_str_passes_with_existing_url() -> Result<(), String> {
1268 let url: &str = "https://example.org/news/today.html?tag=top#headline";
1269 match Parts::try_from(url) {
1270 Ok(parts) => validate_parts_and_url(parts, Some(url.to_string())),
1271 Err(e) => Err(format!("could not create Options: {}", e)),
1272 }
1273 }
1274
1275 #[test]
1276 fn test_parts_try_from_str_fails_with_non_existing_url() -> Result<(), String> {
1277 let url: &str = "https://unresolvable.example.org/news/today.html?tag=top#headline";
1278 let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&str>::try_from(url);
1279 match parts {
1280 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1281 Err(e)
1282 if e.to_string()
1283 == "failed to lookup address information: Name or service not known" =>
1284 {
1285 Ok(())
1286 }
1287 Err(e) => Err(format!(
1288 "unexpected error creating Parts from a URL: {}",
1289 e.to_string()
1290 )),
1291 }
1292 }
1293
1294 #[test]
1295 fn test_parts_try_from_str_fails_with_non_supported_scheme() -> Result<(), String> {
1296 let url: &str = "news://example.org/news/today";
1297 let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&str>::try_from(url);
1298 match parts {
1299 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1300 Err(e)
1301 if e.to_string()
1302 == "Only HTTP(S) standard ports are suported. news given" =>
1303 {
1304 Ok(())
1305 }
1306 Err(e) => Err(format!(
1307 "unexpected error creating Parts from a URL: {}",
1308 e.to_string()
1309 )),
1310 }
1311 }
1312
1313 #[test]
1314 fn test_parts_try_from_url_passes_with_existing_url() -> Result<(), String> {
1315 let url: &str = "https://example.org/news/today.html?tag=top#headline";
1316 let url: Url = Url::parse(url).expect("cannot parse test URL");
1317 let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1318 match parts {
1319 Ok(parts) => validate_parts_and_url(parts, Some(url.to_string())),
1320 Err(e) => Err(format!("could not create Options: {}", e)),
1321 }
1322 }
1323
1324 #[test]
1325 fn test_parts_try_from_url_fails_with_non_existing_url() -> Result<(), String> {
1326 let url: &str = "https://unresolvable.example.org/news/today.html?tag=top#headline";
1327 let url: Url = Url::parse(url).expect("cannot parse test URL");
1328 let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1329 match parts {
1330 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1331 Err(e)
1332 if e.to_string()
1333 == "failed to lookup address information: Name or service not known" =>
1334 {
1335 Ok(())
1336 }
1337 Err(e) => Err(format!(
1338 "unexpected error creating Parts from a URL: {}",
1339 e.to_string()
1340 )),
1341 }
1342 }
1343
1344 #[test]
1345 fn test_parts_try_from_url_fails_with_non_supported_scheme() -> Result<(), String> {
1346 let url: &str = "news://example.org/news/today";
1347 let url: Url = Url::parse(url).expect("cannot parse test URL");
1348 let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1349 match parts {
1350 Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1351 Err(e)
1352 if e.to_string()
1353 == "Only HTTP(S) standard ports are suported. news given" =>
1354 {
1355 Ok(())
1356 }
1357 Err(e) => Err(format!(
1358 "unexpected error creating Parts from a URL: {}",
1359 e.to_string()
1360 )),
1361 }
1362 }
1363 }
1364 }
1365
1366 #[test]
1367 fn test_clipboard_content_passes_trivially() -> Result<(), String> {
1368 let expected = format!("{:?}", Instant::now());
1374 let clipboard = Clipboard::new().unwrap();
1375 let atom_clipboard = clipboard.setter.atoms.clipboard;
1376 let atom_utf8string = clipboard.setter.atoms.utf8_string;
1377 clipboard
1378 .store(atom_clipboard, atom_utf8string, expected.as_bytes())
1379 .map_err(|e| format!("could not store a value in the clipboard: {}", e))?;
1380 match clipboard_content()
1381 .map_err(|e| format!("could not get a value from the cliboard: {}", e))
1382 {
1383 Ok(content) if content == expected => Ok(()),
1384 Ok(content) => Err(format!("unexpected paste content: {}", content)),
1385 Err(e) => Err(format!("could not read the clipboard: {}", e)),
1386 }
1387 }
1388
1389 #[test]
1390 fn test_parts_http_request_passes_without_url_query() -> Result<(), String> {
1391 let parts: Parts = Parts::try_from("https://example.org/resource")
1392 .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1393 let expected: String = format!("GET /resource HTTP/1.0\r\nHost: example.org\r\n\r\n",);
1394 match parts.http_request() {
1395 req if req == expected => Ok(()),
1396 req => Err(format!("unexpected HTTP request: {}", req)),
1397 }
1398 }
1399
1400 #[test]
1401 fn test_parts_http_request_passes_with_url_query_and_fragment() -> Result<(), String> {
1402 let parts: Parts =
1403 Parts::try_from("https://example.org/resource.html?tag=news#headline")
1404 .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1405 let expected: String =
1406 format!("GET /resource.html?tag=news#headline HTTP/1.0\r\nHost: example.org\r\n\r\n",);
1407 match parts.http_request() {
1408 req if req == expected => Ok(()),
1409 req => Err(format!("unexpected HTTP request: {}", req)),
1410 }
1411 }
1412
1413 #[test]
1414 fn test_parts_http_request_passes_with_url_query() -> Result<(), String> {
1415 let parts: Parts = Parts::try_from("https://www.example.org/resource.html?tag=news")
1416 .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1417 let expected: String =
1418 format!("GET /resource.html?tag=news HTTP/1.0\r\nHost: www.example.org\r\n\r\n",);
1419 match parts.http_request() {
1420 req if req == expected => Ok(()),
1421 req => Err(format!("unexpected HTTP request: {}", req)),
1422 }
1423 }
1424
1425 #[test]
1426 fn test_parts_http_request_passes_with_url_fragment() -> Result<(), String> {
1427 let parts: Parts = Parts::try_from("https://www.example.org/resource.html#headline")
1428 .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1429 let expected: String =
1430 format!("GET /resource.html#headline HTTP/1.0\r\nHost: www.example.org\r\n\r\n",);
1431 match parts.http_request() {
1432 req if req == expected => Ok(()),
1433 req => Err(format!("unexpected HTTP request: {}", req)),
1434 }
1435 }
1436}