use goose::goose::GooseResponse;
use goose::prelude::*;
use http::Uri;
use log::info;
use regex::Regex;
use reqwest::header::HeaderMap;
pub mod drupal;
pub mod text;
#[derive(Clone, Debug)]
struct ValidateStatus {
equals: bool,
status_code: u16,
}
#[derive(Clone, Debug)]
struct ValidateTitle<'a> {
exists: bool,
title: &'a str,
}
#[derive(Clone, Debug)]
struct ValidateText<'a> {
exists: bool,
text: &'a str,
}
#[derive(Clone, Debug)]
struct ValidateHeader<'a> {
exists: bool,
header: &'a str,
value: &'a str,
}
#[derive(Clone, Debug)]
pub struct Validate<'a> {
status: Option<ValidateStatus>,
title: Option<ValidateTitle<'a>>,
texts: Vec<ValidateText<'a>>,
headers: Vec<ValidateHeader<'a>>,
redirect: Option<bool>,
}
impl<'a> Validate<'a> {
pub fn builder() -> ValidateBuilder<'a> {
ValidateBuilder::new()
}
pub fn none() -> Validate<'a> {
Validate::builder().build()
}
}
#[derive(Clone, Debug)]
pub struct ValidateBuilder<'a> {
status: Option<ValidateStatus>,
title: Option<ValidateTitle<'a>>,
texts: Vec<ValidateText<'a>>,
headers: Vec<ValidateHeader<'a>>,
redirect: Option<bool>,
}
impl<'a> ValidateBuilder<'a> {
fn new() -> Self {
Self {
status: None,
title: None,
texts: vec![],
headers: vec![],
redirect: None,
}
}
pub fn status(mut self, status_code: u16) -> Self {
self.status = Some(ValidateStatus {
equals: true,
status_code,
});
self
}
pub fn not_status(mut self, status_code: u16) -> Self {
self.status = Some(ValidateStatus {
equals: false,
status_code,
});
self
}
pub fn title(mut self, title: impl Into<&'a str>) -> Self {
self.title = Some(ValidateTitle {
exists: true,
title: title.into(),
});
self
}
pub fn not_title(mut self, title: impl Into<&'a str>) -> Self {
self.title = Some(ValidateTitle {
exists: false,
title: title.into(),
});
self
}
pub fn text(mut self, text: &'a str) -> Self {
self.texts.push(ValidateText { exists: true, text });
self
}
pub fn not_text(mut self, text: &'a str) -> Self {
self.texts.push(ValidateText {
exists: false,
text,
});
self
}
pub fn texts(mut self, texts: Vec<&'a str>) -> Self {
for text in texts {
self = self.text(text);
}
self
}
pub fn not_texts(mut self, texts: Vec<&'a str>) -> Self {
for text in texts {
self = self.not_text(text);
}
self
}
pub fn header(mut self, header: impl Into<&'a str>) -> Self {
self.headers.push(ValidateHeader {
exists: true,
header: header.into(),
value: "",
});
self
}
pub fn not_header(mut self, header: impl Into<&'a str>) -> Self {
self.headers.push(ValidateHeader {
exists: false,
header: header.into(),
value: "",
});
self
}
pub fn header_value(mut self, header: impl Into<&'a str>, value: impl Into<&'a str>) -> Self {
self.headers.push(ValidateHeader {
exists: true,
header: header.into(),
value: value.into(),
});
self
}
pub fn not_header_value(
mut self,
header: impl Into<&'a str>,
value: impl Into<&'a str>,
) -> Self {
self.headers.push(ValidateHeader {
exists: false,
header: header.into(),
value: value.into(),
});
self
}
pub fn redirect(mut self, redirect: impl Into<bool>) -> Self {
self.redirect = Some(redirect.into());
self
}
pub fn build(self) -> Validate<'a> {
let Self {
status,
title,
texts,
headers,
redirect,
} = self;
Validate {
status,
title,
texts,
headers,
redirect,
}
}
}
pub fn get_html_header(html: &str) -> Option<String> {
let re = Regex::new(r#"<head(.*?)</head>"#).unwrap();
let line = html.replace('\n', "");
re.captures(&line).map(|value| value[0].to_string())
}
pub fn get_title(html: &str) -> Option<String> {
let re = Regex::new(r#"<title>(.*?)</title>"#).unwrap();
let line = html.replace('\n', "");
re.captures(&line).map(|value| value[1].to_string())
}
pub fn valid_title(html: &str, title: &str) -> bool {
let html_header = get_html_header(html).map_or_else(|| "".to_string(), |h| h);
let html_title = get_title(&html_header).map_or_else(|| "".to_string(), |t| t);
html_title
.to_ascii_lowercase()
.contains(title.to_ascii_lowercase().as_str())
}
pub fn valid_text(html: &str, text: &str) -> bool {
html.contains(text)
}
pub fn header_is_set(headers: &HeaderMap, header: &str) -> bool {
headers.contains_key(header)
}
pub fn valid_header_value<'a>(headers: &HeaderMap, header: (&'a str, &'a str)) -> bool {
if header.0.is_empty() {
info!("no header specified");
return false;
}
if header_is_set(headers, header.0) {
if header.1.is_empty() {
false
} else {
let header_value = match headers.get(header.0) {
Some(v) => v.to_str().unwrap_or(""),
None => "",
};
if header_value.contains(header.1) {
true
} else {
info!(
r#"header does not contain expected value: "{}: {}""#,
header.0, header.1
);
false
}
}
} else {
info!("header ({}) not set", header.0);
false
}
}
fn valid_local_uri(user: &mut GooseUser, uri: &str) -> bool {
match uri.parse::<Uri>() {
Ok(parsed_uri) => {
if let Some(parsed_host) = parsed_uri.host() {
if parsed_host == user.base_url.host_str().unwrap() {
true
} else {
false
}
} else {
true
}
}
Err(_) => {
let url_leading = format!("/{}", uri);
match url_leading.parse::<Uri>() {
Ok(_) => {
true
}
Err(_) => {
false
}
}
}
}
}
pub async fn get_src_elements(user: &mut GooseUser, html: &str) -> Vec<String> {
let src_elements = Regex::new(r#"(?i)src="(.*?)""#).unwrap();
let mut elements: Vec<String> = Vec::new();
for url in src_elements.captures_iter(html) {
if valid_local_uri(user, &url[1]) {
elements.push(url[1].to_string());
}
}
elements
}
pub async fn get_css_elements(user: &mut GooseUser, html: &str) -> Vec<String> {
let css = Regex::new(r#"(?i)href="(.*?\.css.*?)""#).unwrap();
let mut elements: Vec<String> = Vec::new();
for url in css.captures_iter(html) {
if valid_local_uri(user, &url[1]) {
elements.push(url[1].to_string());
}
}
elements
}
pub async fn load_static_elements(user: &mut GooseUser, html: &str) {
for url in get_src_elements(user, html).await {
let is_js = url.contains(".js");
let resource_type = if is_js { "js" } else { "img" };
let _ = user
.get_named(&url, &("static asset: ".to_owned() + resource_type))
.await;
}
for url in get_css_elements(user, html).await {
let _ = user.get_named(&url, "static asset: css").await;
}
}
pub async fn validate_page<'a>(
user: &mut GooseUser,
mut goose: GooseResponse,
validate: &'a Validate<'a>,
) -> Result<String, Box<TransactionError>> {
let empty = "".to_string();
match goose.response {
Ok(response) => {
if let Some(redirect) = validate.redirect {
if goose.request.redirected != redirect {
let headers = &response.headers().clone();
let html = response.text().await.unwrap_or_else(|_| "".to_string());
let error = if redirect {
format!("{}: did not redirect", goose.request.raw.url)
} else {
format!("{}: redirected unexpectedly", goose.request.raw.url)
};
user.set_failure(&error, &mut goose.request, Some(headers), Some(&html))?;
return Ok(html);
}
}
if let Some(validate_status) = validate.status.as_ref() {
if !validate_status.equals && response.status() == validate_status.status_code {
let headers = &response.headers().clone();
let response_status = response.status();
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: response status == {}]: {}",
goose.request.raw.url, validate_status.status_code, response_status
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
} else if validate_status.equals && response.status() != validate_status.status_code
{
let headers = &response.headers().clone();
let response_status = response.status();
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: response status != {}]: {}",
goose.request.raw.url, validate_status.status_code, response_status
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
}
let headers = &response.headers().clone();
for validate_header in &validate.headers {
if !validate_header.exists {
if header_is_set(headers, validate_header.header) {
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: header included in response: {:?}",
goose.request.raw.url, validate_header.header
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
if !validate_header.value.is_empty()
&& valid_header_value(
headers,
(validate_header.header, validate_header.value),
)
{
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: header contains unexpected value: {:?}",
goose.request.raw.url, validate_header.value
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
} else {
if !header_is_set(headers, validate_header.header) {
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: header not included in response: {:?}",
goose.request.raw.url, validate_header.header
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
if !validate_header.value.is_empty()
&& !valid_header_value(
headers,
(validate_header.header, validate_header.value),
)
{
let html = response.text().await.unwrap_or_else(|_| "".to_string());
user.set_failure(
&format!(
"{}: header does not contain expected value: {:?}",
goose.request.raw.url, validate_header.value
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
}
}
match response.text().await {
Ok(html) => {
if let Some(validate_title) = validate.title.as_ref() {
if !validate_title.exists && valid_title(&html, validate_title.title) {
user.set_failure(
&format!(
"{}: title found: {}",
goose.request.raw.url, validate_title.title
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
} else if validate_title.exists && !valid_title(&html, validate_title.title)
{
user.set_failure(
&format!(
"{}: title not found: {}",
goose.request.raw.url, validate_title.title
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
}
for validate_text in &validate.texts {
if !validate_text.exists && valid_text(&html, validate_text.text) {
user.set_failure(
&format!(
"{}: text found on page: {}",
goose.request.raw.url, validate_text.text
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
} else if validate_text.exists && !valid_text(&html, validate_text.text) {
user.set_failure(
&format!(
"{}: text not found on page: {}",
goose.request.raw.url, validate_text.text
),
&mut goose.request,
Some(headers),
Some(&html),
)?;
return Ok(html);
}
}
Ok(html)
}
Err(e) => {
user.set_failure(
&format!("{}: failed to parse page: {}", goose.request.raw.url, e),
&mut goose.request,
Some(headers),
None,
)?;
Ok(empty)
}
}
}
Err(e) => {
user.set_failure(
&format!("{}: no response from server: {}", goose.request.raw.url, e),
&mut goose.request,
None,
None,
)?;
Ok(empty)
}
}
}
pub async fn validate_and_load_static_assets<'a>(
user: &mut GooseUser,
goose: GooseResponse,
validate: &'a Validate<'a>,
) -> Result<String, Box<TransactionError>> {
match validate_page(user, goose, validate).await {
Ok(html) => {
load_static_elements(user, &html).await;
Ok(html)
}
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use goose::config::GooseConfiguration;
use goose::goose::get_base_url;
use gumdrop::Options;
const EMPTY_ARGS: Vec<&str> = vec![];
const HOST: &str = "http://example.com";
#[tokio::test]
async fn get_static_elements() {
const HTML: &str = r#"<!DOCTYPE html>
<html>
<body>
<!-- 4 valid CSS paths -->
<!-- valid local http path including host -->
<link href="http://example.com/example.css" rel="stylesheet" />
<!-- valid local http path including host and query parameter -->
<link href="http://example.com/example.css?version=abc123" rel="stylesheet" />
<!-- invalid http path on different subdomain -->
<link href="http://other.example.com/example.css" rel="stylesheet" />
<!-- invalid http path on different domain -->
<link href="http://other.com/example.css" rel="stylesheet" />
<!-- invalid http path not ending in css -->
<link href="http://example.com/example" rel="stylesheet" />
<!-- valid relative path -->
<link href="path/to/example.css" rel="stylesheet" />
<!-- valid absolute path -->
<link href="/path/to/example.css" rel="stylesheet" />
<!-- 4 valid image paths -->
<!-- valid local http path including host -->
<img src="http://example.com/example.jpg" alt="example image" width="10" height="10">
<!-- invalid http path on different subdomain -->
<img src="http://another.example.com/example.jpg" alt="example image" width="10" height="10">
<!-- invalid http path on different domain -->
<img src="http://another.com/example.jpg" alt="example image" width="10" height="10">
<!-- valid relative path -->
<img src="path/to/example.gif" alt="example image" />
<!-- valid absolute path -->
<img src="/path/to/example.png" alt="example image" />
<!-- valid absolute path with query parameter -->
<img src="/path/to/example.jpg?itok=Q8u7GC4u" alt="example image" />
<!-- 3 valid JS paths -->
<!-- valid local http path including host -->
<script src="http://example.com/example.js"></script>
<!-- invalid http path on different subdomain -->
<script src="http://different.example.com/example.js"></script>
<!-- valid relative path -->
<script src="path/to/example.js"></script>
<!-- valid absolute path -->
<script src="/path/to/example.js"></script>
</body>
</html>"#;
let configuration = GooseConfiguration::parse_args_default(&EMPTY_ARGS).unwrap();
let base_url = get_base_url(Some(HOST.to_string()), None, None).unwrap();
let mut user =
GooseUser::new(0, "".to_string(), base_url, &configuration, 0, None).unwrap();
let urls = get_css_elements(&mut user, HTML).await;
if urls.len() != 4 {
eprintln!(
"expected matches: {:#?}",
vec![
"http://example.com/example.css",
"http://example.com/example.css?version=abc123",
"path/to/example.css",
"/path/to/example.css",
]
);
eprintln!("actual matches: {:#?}", urls);
}
assert_eq!(urls.len(), 4);
let urls = get_src_elements(&mut user, HTML).await;
if urls.len() != 7 {
eprintln!(
"expected matches: {:#?}",
vec![
"http://example.com/example.jpg",
"path/to/example.gif",
"/path/to/example.png",
"/path/to/example.jpg?itok=Q8u7GC4u",
"http://example.com/example.js",
"path/to/example.js",
"/path/to/example.js",
]
);
eprintln!("actual matches: {:#?}", urls);
}
assert_eq!(urls.len(), 7);
}
}