use crate::prelude::*;
use std::str::FromStr;
use std::{
fs::{self, File},
path::{Path, PathBuf},
};
use askama::Template;
use atom_syndication::{
CategoryBuilder, ContentBuilder, Entry, EntryBuilder, FeedBuilder, FixedDateTime, LinkBuilder,
PersonBuilder, Text,
};
use chrono::{Date, Duration, NaiveDate, Utc};
use comrak::{markdown_to_html, ComrakOptions};
use rust_embed::RustEmbed;
use xml::escape::escape_str_attribute;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;
#[derive(Template)]
#[template(path = "advisories.html")]
struct AdvisoriesListTemplate {
advisories_per_year: Vec<AdvisoriesPerYear>,
}
struct AdvisoriesPerYear {
year: u32,
advisories: Vec<(rustsec::Advisory, String, String)>,
}
#[derive(Template)]
#[template(path = "advisories-sublist.html")]
struct AdvisoriesSubList {
title: String,
group_by: String,
advisories: Vec<(rustsec::Advisory, String, String)>,
}
#[derive(Template)]
#[template(path = "advisory.html")]
struct AdvisoryTemplate<'a> {
advisory: &'a rustsec::Advisory,
rendered_description: String,
rendered_title: String,
}
#[derive(Template)]
#[template(path = "advisory-content.html")]
struct AdvisoryContentTemplate<'a> {
advisory: &'a rustsec::Advisory,
rendered_description: String,
rendered_title: String,
}
#[derive(Template)]
#[template(path = "sublist-index.html")]
struct ItemsList {
title: String,
items: Vec<(String, String, Option<usize>)>,
}
fn render_list_index(title: &str, mut items: Vec<(String, String, Option<usize>)>, folder: &Path) {
items.sort_by(|a, b| a.0.to_lowercase().partial_cmp(&b.0.to_lowercase()).unwrap());
let index_data = ItemsList {
title: title.to_owned(),
items,
};
let index_path = folder.join("index.html");
fs::write(&index_path, index_data.render().unwrap()).unwrap();
status_ok!("Rendered", "{}", index_path.display());
}
pub fn render_advisories(output_folder: PathBuf) {
let mut advisories: Vec<rustsec::Advisory> =
rustsec::Database::fetch().unwrap().into_iter().collect();
let advisories_folder = output_folder.join("advisories");
fs::create_dir_all(&advisories_folder).unwrap();
for advisory in &advisories {
let output_path = advisories_folder.join(advisory.id().as_str().to_owned() + ".html");
let rendered_description =
markdown_to_html(advisory.description(), &ComrakOptions::default());
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_tmpl = AdvisoryTemplate {
advisory,
rendered_description,
rendered_title,
};
fs::write(&output_path, advisory_tmpl.render().unwrap()).unwrap();
status_ok!("Rendered", "{}", output_path.display());
}
copy_static_assets(&output_folder);
let index_page = IndexTemplate.render().unwrap();
fs::write(output_folder.join("index.html"), index_page).unwrap();
#[allow(clippy::unnecessary_sort_by)]
advisories.sort_by(|a, b| b.date().cmp(&a.date()));
let mut advisories_per_year = Vec::<AdvisoriesPerYear>::new();
for advisory in advisories.clone() {
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_title_type = title_type(&advisory);
match advisories_per_year
.iter_mut()
.find(|per_year| per_year.year == advisory.date().year())
{
Some(advisories_for_year) => {
advisories_for_year
.advisories
.push((advisory, rendered_title, advisory_title_type))
}
None => advisories_per_year.push(AdvisoriesPerYear {
year: advisory.date().year(),
advisories: vec![(advisory, rendered_title, advisory_title_type)],
}),
}
}
let advisories_page_tmpl = AdvisoriesListTemplate {
advisories_per_year,
};
let advisories_page = advisories_page_tmpl.render().unwrap();
fs::write(advisories_folder.join("index.html"), advisories_page).unwrap();
status_ok!(
"Completed",
"{} advisories rendered as HTML",
advisories.len()
);
let mut advisories_per_package = Vec::<AdvisoriesSubList>::new();
let mut packages = Vec::<(String, String, Option<usize>)>::new();
for advisory in advisories.clone() {
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_title_type = title_type(&advisory);
let package = advisory.metadata.package.to_string();
match packages.iter_mut().find(|(n, _, _)| *n == package) {
Some(p) => p.2 = Some(p.2.unwrap() + 1),
None => packages.push((
package.clone(),
format!("/packages/{}.html", package.clone()),
Some(1),
)),
}
match advisories_per_package
.iter_mut()
.find(|advisories| advisories.group_by == advisory.metadata.package.to_string())
{
Some(advisories) => {
advisories
.advisories
.push((advisory, rendered_title, advisory_title_type))
}
None => advisories_per_package.push(AdvisoriesSubList {
title: format!("Advisories for package '{}'", advisory.metadata.package),
group_by: advisory.metadata.package.to_string(),
advisories: vec![(advisory, rendered_title, advisory_title_type)],
}),
}
}
let folder = output_folder.join("packages");
fs::create_dir_all(&folder).unwrap();
render_list_index("Packages", packages, folder.as_ref());
for tpl in &advisories_per_package {
let output_path = folder.join(tpl.group_by.clone() + ".html");
fs::write(&output_path, tpl.render().unwrap()).unwrap();
status_ok!("Rendered", "{}", output_path.display());
}
status_ok!(
"Completed",
"{} packages rendered as HTML",
advisories_per_package.len()
);
let mut advisories_per_keyword = Vec::<AdvisoriesSubList>::new();
let mut keywords = Vec::<(String, String, Option<usize>)>::new();
for advisory in advisories.clone() {
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_title_type = title_type(&advisory);
let mut slug_keywords = advisory
.metadata
.keywords
.as_slice()
.iter()
.map(|k| filters::safe_keyword(k.as_str()).unwrap())
.collect::<Vec<String>>();
slug_keywords.sort();
slug_keywords.dedup();
for keyword in slug_keywords {
if keywords.iter().find(|(n, _, _)| *n == keyword).is_none() {
keywords.push((
keyword.clone(),
format!("/keywords/{}.html", keyword.clone()),
None,
));
}
match advisories_per_keyword
.iter_mut()
.find(|advisories| advisories.group_by == keyword.as_str())
{
Some(advisories) => advisories.advisories.push((
advisory.clone(),
rendered_title.clone(),
advisory_title_type.clone(),
)),
None => advisories_per_keyword.push(AdvisoriesSubList {
title: format!("Advisories with keyword '{}'", keyword.as_str()),
group_by: keyword.as_str().to_string(),
advisories: vec![(
advisory.clone(),
rendered_title.clone(),
advisory_title_type.clone(),
)],
}),
}
}
}
let folder = output_folder.join("keywords");
fs::create_dir_all(&folder).unwrap();
render_list_index("Keywords", keywords, folder.as_ref());
for tpl in &advisories_per_keyword {
let output_path = folder.join(tpl.group_by.clone() + ".html");
fs::write(&output_path, tpl.render().unwrap()).unwrap();
status_ok!("Rendered", "{}", output_path.display());
}
status_ok!(
"Completed",
"{} packages rendered as HTML",
advisories_per_keyword.len()
);
let mut advisories_per_category = Vec::<AdvisoriesSubList>::new();
let mut categories = Vec::<(String, String, Option<usize>)>::new();
for advisory in advisories.clone() {
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_title_type = title_type(&advisory);
for category in advisory.metadata.categories.as_slice() {
if categories
.iter()
.find(|(n, _, _)| n == category.name())
.is_none()
{
categories.push((
category.name().to_owned(),
format!("/categories/{}.html", category.name()),
None,
));
}
match advisories_per_category
.iter_mut()
.find(|advisories| advisories.group_by == category.name())
{
Some(advisories) => advisories.advisories.push((
advisory.clone(),
rendered_title.clone(),
advisory_title_type.clone(),
)),
None => advisories_per_category.push(AdvisoriesSubList {
title: format!("Advisories in category '{}'", category.name()),
group_by: category.name().to_string(),
advisories: vec![(
advisory.clone(),
rendered_title.clone(),
advisory_title_type.clone(),
)],
}),
}
}
}
let folder = output_folder.join("categories");
fs::create_dir_all(&folder).unwrap();
render_list_index("Categories", categories, folder.as_ref());
for tpl in &advisories_per_category {
let output_path = folder.join(tpl.group_by.clone() + ".html");
fs::write(&output_path, tpl.render().unwrap()).unwrap();
status_ok!("Rendered", "{}", output_path.display());
}
status_ok!(
"Completed",
"{} packages rendered as HTML",
advisories_per_category.len() + 1
);
let feed_path = output_folder.join("feed.xml");
let min_feed_len = 10;
let last_week_len = advisories
.iter()
.take_while(|a| {
Date::from_utc(
NaiveDate::parse_from_str(a.date().as_str(), "%Y-%m-%d").unwrap(),
Utc,
) > Utc::today() - Duration::days(8)
})
.count();
let len = if advisories.len() < min_feed_len {
advisories.len()
} else if last_week_len > min_feed_len {
last_week_len
} else {
min_feed_len
};
render_feed(&feed_path, &advisories[..len]);
status_ok!("Rendered", "{}", feed_path.display());
status_ok!("Completed", "{} advisories rendered in atom feed", len);
}
fn title_type(advisory: &rustsec::Advisory) -> String {
use rustsec::advisory::informational::Informational;
let id = advisory.id().as_str();
let package = advisory.metadata.package.as_str();
match &advisory.metadata.informational {
Some(Informational::Notice) => format!("{}: Security notice about {}", id, package),
Some(Informational::Unmaintained) => format!("{}: {} is unmaintained", id, package),
Some(Informational::Unsound) => format!("{}: Unsoundness in {}", id, package),
Some(Informational::Other(s)) => format!("{}: {} is {}", id, package, s),
Some(_) => format!("{}: Advisory for {}", id, package),
None => format!("{}: Vulnerability in {}", id, package),
}
}
fn render_feed(output_path: &Path, advisories: &[rustsec::Advisory]) {
let mut entries: Vec<Entry> = vec![];
let author = PersonBuilder::default().name("RustSec").build();
let latest_advisory_date =
advisories.first().unwrap().date().as_str().to_owned() + "T00:00:00+00:00";
for advisory in advisories {
let escaped_title_type = escape_str_attribute(&title_type(advisory)).into_owned();
let escaped_title = escape_str_attribute(advisory.title()).into_owned();
let date_time = advisory.date().as_str().to_owned() + "T00:00:00+00:00";
let url = "https://rustsec.org/advisories/".to_owned() + advisory.id().as_str() + ".html";
let link = LinkBuilder::default()
.rel("alternate")
.mime_type(Some("text/html".to_owned()))
.title(escaped_title_type.clone())
.href(url.clone())
.build();
let mut categories = vec![];
for category in &advisory.metadata.categories {
categories.push(
CategoryBuilder::default()
.term(category.to_string())
.build(),
);
}
let rendered_description =
markdown_to_html(advisory.description(), &ComrakOptions::default());
let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
let advisory_tmpl = AdvisoryContentTemplate {
advisory,
rendered_description,
rendered_title,
};
let html = escape_str_attribute(&advisory_tmpl.render().unwrap()).into_owned();
let content = ContentBuilder::default()
.content_type(Some("html".to_owned()))
.lang("en".to_owned())
.value(Some(html))
.build();
let mut summary = Text::plain(escaped_title);
summary.lang = Some("en".to_owned());
let item = EntryBuilder::default()
.id(url)
.title(escaped_title_type)
.summary(Some(summary))
.links(vec![link])
.categories(categories)
.published(Some(FixedDateTime::from_str(&date_time).unwrap()))
.updated(FixedDateTime::from_str(&date_time).unwrap())
.content(Some(content))
.build();
entries.push(item);
}
let self_url = "https://rustsec.org/feed.xml";
let alternate_link = LinkBuilder::default()
.href("https://rustsec.org/")
.rel("alternate")
.mime_type(Some("text/html".to_owned()))
.build();
let self_link = LinkBuilder::default()
.href(self_url)
.rel("self")
.mime_type(Some("application/atom+xml".to_owned()))
.build();
let mut subtitle = Text::plain("Security advisories filed against Rust crates".to_owned());
subtitle.lang = Some("en".to_owned());
let feed = FeedBuilder::default()
.id(self_url)
.title("RustSec Advisories")
.subtitle(Some(subtitle))
.links(vec![self_link, alternate_link])
.icon("https://rustsec.org/favicon.ico".to_owned())
.entries(entries)
.updated(FixedDateTime::from_str(&latest_advisory_date).unwrap())
.authors(vec![author])
.build();
let file = File::create(&output_path).unwrap();
feed.write_to(file).unwrap();
}
#[derive(RustEmbed)]
#[folder = "src/web/static/"]
struct StaticAsset;
fn copy_static_assets(output_folder: &Path) {
for file in StaticAsset::iter() {
let asset_path = PathBuf::from(file.as_ref());
if let Some(containing_folder) = asset_path.parent() {
fs::create_dir_all(output_folder.join(containing_folder)).unwrap();
}
let asset = StaticAsset::get(file.as_ref()).unwrap();
fs::write(output_folder.join(file.as_ref()), asset.data).unwrap();
}
}
mod filters {
use chrono::NaiveDate;
use std::convert::TryInto;
pub fn friendly_date(date: &&rustsec::advisory::Date) -> ::askama::Result<String> {
Ok(
NaiveDate::from_ymd(date.year().try_into().unwrap(), date.month(), date.day())
.format("%B %e, %Y")
.to_string(),
)
}
pub fn safe_keyword(s: &str) -> ::askama::Result<String> {
Ok(s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect())
}
}