2019-12-21 21:52:39 +00:00
|
|
|
use lazy_static::lazy_static;
|
2018-09-30 17:17:51 +00:00
|
|
|
use reqwest::header::{HeaderMap, ACCEPT};
|
2019-12-31 09:35:13 +00:00
|
|
|
use reqwest::{blocking::Client, StatusCode};
|
2019-09-04 18:31:19 +00:00
|
|
|
|
2019-10-14 16:31:03 +00:00
|
|
|
use config::LinkChecker;
|
2019-09-04 18:31:19 +00:00
|
|
|
use errors::Result;
|
|
|
|
|
2018-07-16 19:13:00 +00:00
|
|
|
use std::collections::HashMap;
|
|
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
pub struct LinkResult {
|
|
|
|
pub code: Option<StatusCode>,
|
|
|
|
/// Whether the HTTP request didn't make it to getting a HTTP code
|
|
|
|
pub error: Option<String>,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl LinkResult {
|
|
|
|
pub fn is_valid(&self) -> bool {
|
|
|
|
if self.error.is_some() {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Some(c) = self.code {
|
2019-12-24 11:29:50 +00:00
|
|
|
return c.is_success() || c == StatusCode::NOT_MODIFIED;
|
2018-07-16 19:13:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
true
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn message(&self) -> String {
|
|
|
|
if let Some(ref e) = self.error {
|
|
|
|
return e.clone();
|
|
|
|
}
|
|
|
|
|
|
|
|
if let Some(c) = self.code {
|
|
|
|
return format!("{}", c);
|
|
|
|
}
|
|
|
|
|
|
|
|
"Unknown error".to_string()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
lazy_static! {
|
|
|
|
// Keep history of link checks so a rebuild doesn't have to check again
|
|
|
|
static ref LINKS: Arc<RwLock<HashMap<String, LinkResult>>> = Arc::new(RwLock::new(HashMap::new()));
|
|
|
|
}
|
|
|
|
|
2019-10-14 16:31:03 +00:00
|
|
|
pub fn check_url(url: &str, config: &LinkChecker) -> LinkResult {
|
2018-07-16 19:13:00 +00:00
|
|
|
{
|
2018-07-31 13:17:31 +00:00
|
|
|
let guard = LINKS.read().unwrap();
|
2018-07-16 19:13:00 +00:00
|
|
|
if let Some(res) = guard.get(url) {
|
|
|
|
return res.clone();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-30 17:17:51 +00:00
|
|
|
let mut headers = HeaderMap::new();
|
2018-09-30 18:20:26 +00:00
|
|
|
headers.insert(ACCEPT, "text/html".parse().unwrap());
|
|
|
|
headers.append(ACCEPT, "*/*".parse().unwrap());
|
2018-08-25 15:50:01 +00:00
|
|
|
|
2020-02-17 19:09:33 +00:00
|
|
|
let client = Client::builder()
|
|
|
|
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
|
|
|
|
.build()
|
|
|
|
.expect("reqwest client build");
|
2018-08-25 15:50:01 +00:00
|
|
|
|
2019-10-14 16:31:03 +00:00
|
|
|
let check_anchor = !config.skip_anchor_prefixes.iter().any(|prefix| url.starts_with(prefix));
|
|
|
|
|
2018-07-16 19:13:00 +00:00
|
|
|
// Need to actually do the link checking
|
2018-08-25 15:50:01 +00:00
|
|
|
let res = match client.get(url).headers(headers).send() {
|
2019-10-14 16:31:03 +00:00
|
|
|
Ok(ref mut response) if check_anchor && has_anchor(url) => {
|
2019-12-31 09:35:13 +00:00
|
|
|
let body = {
|
|
|
|
let mut buf: Vec<u8> = vec![];
|
|
|
|
response.copy_to(&mut buf).unwrap();
|
|
|
|
String::from_utf8(buf).unwrap()
|
|
|
|
};
|
|
|
|
|
|
|
|
match check_page_for_anchor(url, body) {
|
2019-09-04 18:31:19 +00:00
|
|
|
Ok(_) => LinkResult { code: Some(response.status()), error: None },
|
|
|
|
Err(e) => LinkResult { code: None, error: Some(e.to_string()) },
|
|
|
|
}
|
|
|
|
}
|
2019-12-23 08:25:09 +00:00
|
|
|
Ok(response) => {
|
2019-12-24 11:29:50 +00:00
|
|
|
if response.status().is_success() || response.status() == StatusCode::NOT_MODIFIED {
|
2019-12-23 08:25:09 +00:00
|
|
|
LinkResult { code: Some(response.status()), error: None }
|
|
|
|
} else {
|
|
|
|
let error_string = if response.status().is_informational() {
|
2019-12-24 11:29:50 +00:00
|
|
|
format!("Informational status code ({}) received", response.status())
|
2019-12-23 08:25:09 +00:00
|
|
|
} else if response.status().is_redirection() {
|
2019-12-24 11:29:50 +00:00
|
|
|
format!("Redirection status code ({}) received", response.status())
|
2019-12-23 08:25:09 +00:00
|
|
|
} else if response.status().is_client_error() {
|
2019-12-24 11:29:50 +00:00
|
|
|
format!("Client error status code ({}) received", response.status())
|
2019-12-23 08:25:09 +00:00
|
|
|
} else if response.status().is_server_error() {
|
2019-12-24 11:29:50 +00:00
|
|
|
format!("Server error status code ({}) received", response.status())
|
2019-12-23 08:25:09 +00:00
|
|
|
} else {
|
2019-12-24 11:29:50 +00:00
|
|
|
format!("Non-success status code ({}) received", response.status())
|
2019-12-23 08:25:09 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
LinkResult { code: None, error: Some(error_string) }
|
|
|
|
}
|
|
|
|
}
|
2020-01-07 21:05:52 +00:00
|
|
|
Err(e) => LinkResult { code: None, error: Some(e.to_string()) },
|
2018-07-16 19:13:00 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
LINKS.write().unwrap().insert(url.to_string(), res.clone());
|
2018-08-25 15:50:01 +00:00
|
|
|
res
|
2018-07-16 19:13:00 +00:00
|
|
|
}
|
|
|
|
|
2019-09-04 18:31:19 +00:00
|
|
|
fn has_anchor(url: &str) -> bool {
|
|
|
|
match url.find('#') {
|
|
|
|
Some(index) => match url.get(index..=index + 1) {
|
|
|
|
Some("#/") | Some("#!") | None => false,
|
|
|
|
Some(_) => true,
|
|
|
|
},
|
|
|
|
None => false,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-31 09:35:13 +00:00
|
|
|
fn check_page_for_anchor(url: &str, body: String) -> Result<()> {
|
2019-09-04 18:31:19 +00:00
|
|
|
let index = url.find('#').unwrap();
|
|
|
|
let anchor = url.get(index + 1..).unwrap();
|
|
|
|
let checks: [String; 4] = [
|
|
|
|
format!(" id='{}'", anchor),
|
|
|
|
format!(r#" id="{}""#, anchor),
|
|
|
|
format!(" name='{}'", anchor),
|
|
|
|
format!(r#" name="{}""#, anchor),
|
|
|
|
];
|
|
|
|
|
|
|
|
if checks.iter().any(|check| body[..].contains(&check[..])) {
|
|
|
|
Ok(())
|
|
|
|
} else {
|
|
|
|
Err(errors::Error::from(format!("Anchor `#{}` not found on page", anchor)))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-07-16 19:13:00 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
2019-10-14 16:31:03 +00:00
|
|
|
use super::{check_page_for_anchor, check_url, has_anchor, LinkChecker, LINKS};
|
2019-12-23 15:16:56 +00:00
|
|
|
use mockito::mock;
|
2018-07-16 19:13:00 +00:00
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
// NOTE: HTTP mock paths below are randomly generated to avoid name
|
|
|
|
// collisions. Mocks with the same path can sometimes bleed between tests
|
|
|
|
// and cause them to randomly pass/fail. Please make sure to use unique
|
|
|
|
// paths when adding or modifying tests that use Mockito.
|
|
|
|
|
2018-07-16 19:13:00 +00:00
|
|
|
#[test]
|
|
|
|
fn can_validate_ok_links() {
|
2019-12-24 11:29:50 +00:00
|
|
|
let url = format!("{}{}", mockito::server_url(), "/ekbtwxfhjw");
|
|
|
|
let _m = mock("GET", "/ekbtwxfhjw")
|
|
|
|
.with_header("Content-Type", "text/html")
|
2019-12-23 15:16:56 +00:00
|
|
|
.with_body(format!(
|
|
|
|
r#"<!DOCTYPE html>
|
|
|
|
<html>
|
|
|
|
<head>
|
|
|
|
<title>Test</title>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<a href="{}">Mock URL</a>
|
|
|
|
</body>
|
|
|
|
</html>
|
|
|
|
"#,
|
|
|
|
url
|
|
|
|
))
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
2018-07-16 19:13:00 +00:00
|
|
|
assert!(res.is_valid());
|
2019-12-23 15:16:56 +00:00
|
|
|
assert!(LINKS.read().unwrap().get(&url).is_some());
|
2018-07-16 19:13:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
2019-12-24 11:29:50 +00:00
|
|
|
fn can_follow_301_links() {
|
|
|
|
let _m1 = mock("GET", "/c7qrtrv3zz")
|
|
|
|
.with_status(301)
|
|
|
|
.with_header("Content-Type", "text/plain")
|
|
|
|
.with_header("Location", format!("{}/rbs5avjs8e", mockito::server_url()).as_str())
|
|
|
|
.with_body("Redirecting...")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let _m2 = mock("GET", "/rbs5avjs8e")
|
|
|
|
.with_header("Content-Type", "text/plain")
|
|
|
|
.with_body("Test")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let url = format!("{}{}", mockito::server_url(), "/c7qrtrv3zz");
|
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
|
|
|
assert!(res.is_valid());
|
|
|
|
assert!(res.code.is_some());
|
|
|
|
assert!(res.error.is_none());
|
|
|
|
}
|
|
|
|
|
2020-02-17 19:09:33 +00:00
|
|
|
#[test]
|
|
|
|
fn set_default_user_agent() {
|
|
|
|
let user_agent = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
|
|
|
let _m1 = mock("GET", "/C4Szbfnvj6M0LoPk")
|
|
|
|
.match_header("User-Agent", user_agent)
|
|
|
|
.with_status(200)
|
|
|
|
.with_body("Test")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let url = format!("{}{}", mockito::server_url(), "/C4Szbfnvj6M0LoPk");
|
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
|
|
|
assert!(res.is_valid());
|
|
|
|
assert!(res.code.is_some());
|
|
|
|
assert!(res.error.is_none());
|
|
|
|
}
|
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
#[test]
|
|
|
|
fn can_fail_301_to_404_links() {
|
|
|
|
let _m1 = mock("GET", "/cav9vibhsc")
|
|
|
|
.with_status(301)
|
|
|
|
.with_header("Content-Type", "text/plain")
|
|
|
|
.with_header("Location", format!("{}/72zmfg4smd", mockito::server_url()).as_str())
|
|
|
|
.with_body("Redirecting...")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let _m2 = mock("GET", "/72zmfg4smd")
|
|
|
|
.with_status(404)
|
|
|
|
.with_header("Content-Type", "text/plain")
|
|
|
|
.with_body("Not Found")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let url = format!("{}{}", mockito::server_url(), "/cav9vibhsc");
|
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
2018-07-16 19:13:00 +00:00
|
|
|
assert_eq!(res.is_valid(), false);
|
|
|
|
assert!(res.code.is_none());
|
|
|
|
assert!(res.error.is_some());
|
|
|
|
}
|
2019-09-04 18:31:19 +00:00
|
|
|
|
2019-12-23 15:16:56 +00:00
|
|
|
#[test]
|
|
|
|
fn can_fail_404_links() {
|
2019-12-24 11:29:50 +00:00
|
|
|
let _m = mock("GET", "/nlhab9c1vc")
|
2019-12-23 15:16:56 +00:00
|
|
|
.with_status(404)
|
2019-12-24 11:29:50 +00:00
|
|
|
.with_header("Content-Type", "text/plain")
|
2019-12-23 15:16:56 +00:00
|
|
|
.with_body("Not Found")
|
|
|
|
.create();
|
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
let url = format!("{}{}", mockito::server_url(), "/nlhab9c1vc");
|
2019-12-23 15:16:56 +00:00
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
|
|
|
assert_eq!(res.is_valid(), false);
|
|
|
|
assert!(res.code.is_none());
|
|
|
|
assert!(res.error.is_some());
|
|
|
|
}
|
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
#[test]
|
|
|
|
fn can_fail_500_links() {
|
|
|
|
let _m = mock("GET", "/qdbrssazes")
|
|
|
|
.with_status(500)
|
|
|
|
.with_header("Content-Type", "text/plain")
|
|
|
|
.with_body("Internal Server Error")
|
|
|
|
.create();
|
|
|
|
|
|
|
|
let url = format!("{}{}", mockito::server_url(), "/qdbrssazes");
|
|
|
|
let res = check_url(&url, &LinkChecker::default());
|
|
|
|
assert_eq!(res.is_valid(), false);
|
|
|
|
assert!(res.code.is_none());
|
|
|
|
assert!(res.error.is_some());
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn can_fail_unresolved_links() {
|
|
|
|
let res = check_url("https://t6l5cn9lpm.lxizfnzckd", &LinkChecker::default());
|
|
|
|
assert_eq!(res.is_valid(), false);
|
|
|
|
assert!(res.code.is_none());
|
|
|
|
assert!(res.error.is_some());
|
|
|
|
}
|
|
|
|
|
2019-09-04 18:31:19 +00:00
|
|
|
#[test]
|
|
|
|
fn can_validate_anchors() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect";
|
2019-12-23 15:16:56 +00:00
|
|
|
let body = r#"<body><h3 id="method.collect">collect</h3></body>"#.to_string();
|
2019-12-31 09:35:13 +00:00
|
|
|
let res = check_page_for_anchor(url, body);
|
2019-09-04 18:31:19 +00:00
|
|
|
assert!(res.is_ok());
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn can_validate_anchors_with_other_quotes() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect";
|
|
|
|
let body = r#"<body><h3 id="method.collect">collect</h3></body>"#.to_string();
|
2019-12-31 09:35:13 +00:00
|
|
|
let res = check_page_for_anchor(url, body);
|
2019-09-04 18:31:19 +00:00
|
|
|
assert!(res.is_ok());
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn can_validate_anchors_with_name_attr() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect";
|
|
|
|
let body = r#"<body><h3 name="method.collect">collect</h3></body>"#.to_string();
|
2019-12-31 09:35:13 +00:00
|
|
|
let res = check_page_for_anchor(url, body);
|
2019-09-04 18:31:19 +00:00
|
|
|
assert!(res.is_ok());
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn can_fail_when_anchor_not_found() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/iter/trait.Iterator.html#me";
|
2019-12-23 15:16:56 +00:00
|
|
|
let body = r#"<body><h3 id="method.collect">collect</h3></body>"#.to_string();
|
2019-12-31 09:35:13 +00:00
|
|
|
let res = check_page_for_anchor(url, body);
|
2019-09-04 18:31:19 +00:00
|
|
|
assert!(res.is_err());
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn can_check_url_for_anchor() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/index.html#the-rust-standard-library";
|
|
|
|
let res = has_anchor(url);
|
|
|
|
assert_eq!(res, true);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn will_return_false_when_no_anchor() {
|
|
|
|
let url = "https://doc.rust-lang.org/std/index.html";
|
|
|
|
let res = has_anchor(url);
|
|
|
|
assert_eq!(res, false);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn will_return_false_when_has_router_url() {
|
|
|
|
let url = "https://doc.rust-lang.org/#/std";
|
|
|
|
let res = has_anchor(url);
|
|
|
|
assert_eq!(res, false);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn will_return_false_when_has_router_url_alt() {
|
|
|
|
let url = "https://doc.rust-lang.org/#!/std";
|
|
|
|
let res = has_anchor(url);
|
|
|
|
assert_eq!(res, false);
|
|
|
|
}
|
2019-10-14 16:31:03 +00:00
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn skip_anchor_prefixes() {
|
2019-12-23 15:16:56 +00:00
|
|
|
let ignore_url = format!("{}{}", mockito::server_url(), "/ignore/");
|
|
|
|
let config = LinkChecker { skip_prefixes: vec![], skip_anchor_prefixes: vec![ignore_url] };
|
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
let _m1 = mock("GET", "/ignore/i30hobj1cy")
|
|
|
|
.with_header("Content-Type", "text/html")
|
2019-12-23 15:16:56 +00:00
|
|
|
.with_body(
|
|
|
|
r#"<!DOCTYPE html>
|
|
|
|
<html>
|
|
|
|
<head>
|
|
|
|
<title>Ignore</title>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<p id="existent"></p>
|
|
|
|
</body>
|
|
|
|
</html>
|
|
|
|
"#,
|
|
|
|
)
|
|
|
|
.create();
|
2019-10-14 16:31:03 +00:00
|
|
|
|
|
|
|
// anchor check is ignored because the url matches the prefix
|
2019-12-24 11:29:50 +00:00
|
|
|
let ignore = format!("{}{}", mockito::server_url(), "/ignore/i30hobj1cy#nonexistent");
|
2019-12-23 15:16:56 +00:00
|
|
|
assert!(check_url(&ignore, &config).is_valid());
|
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
let _m2 = mock("GET", "/guvqcqwmth")
|
|
|
|
.with_header("Content-Type", "text/html")
|
2019-12-23 15:16:56 +00:00
|
|
|
.with_body(
|
|
|
|
r#"<!DOCTYPE html>
|
|
|
|
<html>
|
|
|
|
<head>
|
|
|
|
<title>Test</title>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<p id="existent"></p>
|
|
|
|
</body>
|
|
|
|
</html>
|
|
|
|
"#,
|
|
|
|
)
|
|
|
|
.create();
|
2019-10-14 16:31:03 +00:00
|
|
|
|
|
|
|
// other anchors are checked
|
2019-12-24 11:29:50 +00:00
|
|
|
let existent = format!("{}{}", mockito::server_url(), "/guvqcqwmth#existent");
|
2019-12-23 15:16:56 +00:00
|
|
|
assert!(check_url(&existent, &config).is_valid());
|
2019-10-14 16:31:03 +00:00
|
|
|
|
2019-12-24 11:29:50 +00:00
|
|
|
let nonexistent = format!("{}{}", mockito::server_url(), "/guvqcqwmth#nonexistent");
|
2019-12-23 15:16:56 +00:00
|
|
|
assert_eq!(check_url(&nonexistent, &config).is_valid(), false);
|
2019-10-14 16:31:03 +00:00
|
|
|
}
|
2018-07-16 19:13:00 +00:00
|
|
|
}
|