1use crate::prelude::*;
4use askama::Template;
5use atom_syndication::{
6 CategoryBuilder, ContentBuilder, Entry, EntryBuilder, FeedBuilder, FixedDateTime, LinkBuilder,
7 PersonBuilder, Text,
8};
9use chrono::{Duration, NaiveDate, Utc};
10use comrak::{markdown_to_html, ComrakOptions};
11use rust_embed::RustEmbed;
12use rustsec::advisory::Id;
13use rustsec::repository::git::GitModificationTimes;
14use rustsec::repository::git::GitPath;
15use rustsec::{advisory, Repository};
16use std::collections::{HashMap, HashSet};
17use std::str::FromStr;
18use std::{
19 fs::{self, File},
20 iter,
21 path::{Path, PathBuf},
22};
23use xml::escape::escape_str_attribute;
24
25#[allow(deprecated)]
27use chrono::Date;
28
29#[derive(Template)]
30#[template(path = "index.html")]
31struct IndexTemplate;
32
33#[derive(Template)]
34#[template(path = "search.html")]
35struct SearchTemplate;
36
37#[derive(Template)]
38#[template(path = "static.html")]
39struct StaticTemplate {
40 title: String,
41 content: String,
42}
43
44#[derive(Template)]
45#[template(path = "advisories.html")]
46struct AdvisoriesListTemplate {
47 advisories: Vec<(rustsec::Advisory, advisory::Date, String, String)>,
49}
50
51#[derive(Template)]
52#[template(path = "advisories-sublist.html")]
53struct AdvisoriesSubList {
54 title: String,
55 group_by: String,
56 advisories: Vec<(rustsec::Advisory, advisory::Date, String, String)>,
58}
59
60#[derive(Template)]
61#[template(path = "advisory.html")]
62struct AdvisoryTemplate<'a> {
63 advisory: &'a rustsec::Advisory,
64 rendered_description: String,
65 rendered_title: String,
66 cdate: advisory::Date,
67 mdate: advisory::Date,
68}
69
70#[derive(Template)]
72#[template(path = "advisory-content.html")]
73struct AdvisoryContentTemplate<'a> {
74 advisory: &'a rustsec::Advisory,
75 rendered_description: String,
76 rendered_title: String,
77 cdate: advisory::Date,
78 mdate: advisory::Date,
79}
80
81#[derive(Template)]
82#[template(path = "sublist-index.html")]
83struct ItemsList {
84 title: String,
85 items: Vec<(String, String, Option<usize>)>,
87}
88
89fn render_list_index(title: &str, mut items: Vec<(String, String, Option<usize>)>, folder: &Path) {
90 items.sort_by(|a, b| a.0.to_lowercase().partial_cmp(&b.0.to_lowercase()).unwrap());
91 let index_data = ItemsList {
92 title: title.to_owned(),
93 items,
94 };
95 let index_path = folder.join("index.html");
96 fs::write(&index_path, index_data.render().unwrap()).unwrap();
97 status_ok!("Rendered", "{}", index_path.display());
98}
99
100pub fn render_advisories(output_folder: PathBuf) {
102 fs::create_dir_all(&output_folder).unwrap();
104
105 let repo = Repository::fetch_default_repo().unwrap();
107 let contributing_path = repo.path().join("CONTRIBUTING.md");
108
109 let mod_times = GitModificationTimes::new(&repo).unwrap();
111
112 let db = rustsec::Database::fetch().unwrap();
114 let mut advisories: Vec<(rustsec::Advisory, advisory::Date, advisory::Date)> = db
115 .into_iter()
116 .map(|a| {
117 let (cdate, mdate) = advisory_dates(&a, &repo, &mod_times);
118 (a, cdate, mdate)
119 })
120 .collect();
121
122 let contributing_md = fs::read_to_string(contributing_path).unwrap();
124 let static_template = StaticTemplate {
125 title: "Reporting Vulnerabilities".to_string(),
126 content: markdown_to_html(&contributing_md, &ComrakOptions::default()),
127 };
128 let contributing_page = static_template.render().unwrap();
129 fs::write(output_folder.join("contributing.html"), contributing_page).unwrap();
130
131 let advisories_folder = output_folder.join("advisories");
133 fs::create_dir_all(&advisories_folder).unwrap();
134
135 for (advisory, cdate, mdate) in &advisories {
136 let output_path = advisories_folder.join(advisory.id().as_str().to_owned() + ".html");
137
138 let rendered_description =
139 markdown_to_html(advisory.description(), &ComrakOptions::default());
140 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
141
142 let advisory_tmpl = AdvisoryTemplate {
143 advisory,
144 rendered_description,
145 rendered_title,
146 cdate: cdate.clone(),
147 mdate: mdate.clone(),
148 };
149 fs::write(&output_path, advisory_tmpl.render().unwrap()).unwrap();
150
151 status_ok!("Rendered", "{}", output_path.display());
152 }
153
154 copy_static_assets(&output_folder);
156
157 let index_template = IndexTemplate;
159 let index_page = index_template.render().unwrap();
160 fs::write(output_folder.join("index.html"), index_page).unwrap();
161
162 let search_page = SearchTemplate.render().unwrap();
164 fs::write(output_folder.join("search.html"), search_page).unwrap();
165
166 #[allow(clippy::unnecessary_sort_by)]
170 advisories.sort_by(|(_, a, _), (_, b, _)| b.cmp(a));
171
172 let mut advisories_index = vec![];
173 for (advisory, cdate, _) in advisories.clone() {
174 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
175 let advisory_title_type = title_type(&advisory);
176 advisories_index.push((advisory, cdate, rendered_title, advisory_title_type));
177 }
178
179 let advisories_page_tmpl = AdvisoriesListTemplate {
180 advisories: advisories_index,
181 };
182 let advisories_page = advisories_page_tmpl.render().unwrap();
183 fs::write(advisories_folder.join("index.html"), advisories_page).unwrap();
184
185 status_ok!(
186 "Completed",
187 "{} advisories rendered as HTML",
188 advisories.len()
189 );
190
191 let mut advisories_per_package = Vec::<AdvisoriesSubList>::new();
193 let mut packages = Vec::<(String, String, Option<usize>)>::new();
194 for (advisory, cdate, _) in advisories.clone() {
195 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
196 let advisory_title_type = title_type(&advisory);
197 let package = advisory.metadata.package.to_string();
198
199 match packages.iter_mut().find(|(n, _, _)| *n == package) {
200 Some(p) => p.2 = Some(p.2.unwrap() + 1),
201 None => packages.push((
202 package.clone(),
203 format!("/packages/{}.html", package.clone()),
204 Some(1),
205 )),
206 }
207 match advisories_per_package
208 .iter_mut()
209 .find(|advisories| advisories.group_by == advisory.metadata.package.to_string())
210 {
211 Some(advisories) => advisories.advisories.push((
212 advisory,
213 cdate.clone(),
214 rendered_title,
215 advisory_title_type,
216 )),
217 None => advisories_per_package.push(AdvisoriesSubList {
218 title: format!("Advisories for package '{}'", advisory.metadata.package),
219 group_by: advisory.metadata.package.to_string(),
220 advisories: vec![(advisory, cdate, rendered_title, advisory_title_type)],
221 }),
222 }
223 }
224 let folder = output_folder.join("packages");
225 fs::create_dir_all(&folder).unwrap();
226 render_list_index("Packages", packages, folder.as_ref());
228 for tpl in &advisories_per_package {
230 let output_path = folder.join(tpl.group_by.clone() + ".html");
231 fs::write(&output_path, tpl.render().unwrap()).unwrap();
232 status_ok!("Rendered", "{}", output_path.display());
233 }
234 status_ok!(
235 "Completed",
236 "{} packages rendered as HTML",
237 advisories_per_package.len()
238 );
239
240 let mut advisories_per_keyword = Vec::<AdvisoriesSubList>::new();
242 let mut keywords = Vec::<(String, String, Option<usize>)>::new();
243 for (advisory, cdate, _) in advisories.clone() {
244 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
245 let advisory_title_type = title_type(&advisory);
246
247 let mut slug_keywords = advisory
249 .metadata
250 .keywords
251 .as_slice()
252 .iter()
253 .map(|k| filters::safe_keyword(k.as_str()).unwrap())
254 .collect::<Vec<String>>();
255 slug_keywords.sort();
256 slug_keywords.dedup();
257
258 for keyword in slug_keywords {
259 if !keywords.iter().any(|(n, _, _)| *n == keyword) {
260 keywords.push((
261 keyword.clone(),
262 format!("/keywords/{}.html", keyword.clone()),
263 None,
264 ));
265 }
266
267 match advisories_per_keyword
268 .iter_mut()
269 .find(|advisories| advisories.group_by == keyword.as_str())
270 {
271 Some(advisories) => advisories.advisories.push((
272 advisory.clone(),
273 cdate.clone(),
274 rendered_title.clone(),
275 advisory_title_type.clone(),
276 )),
277 None => advisories_per_keyword.push(AdvisoriesSubList {
278 title: format!("Advisories with keyword '{}'", keyword.as_str()),
279 group_by: keyword.as_str().to_string(),
280 advisories: vec![(
281 advisory.clone(),
282 cdate.clone(),
283 rendered_title.clone(),
284 advisory_title_type.clone(),
285 )],
286 }),
287 }
288 }
289 }
290 let folder = output_folder.join("keywords");
291 fs::create_dir_all(&folder).unwrap();
292 render_list_index("Keywords", keywords, folder.as_ref());
293 for tpl in &advisories_per_keyword {
294 let output_path = folder.join(tpl.group_by.clone() + ".html");
295 fs::write(&output_path, tpl.render().unwrap()).unwrap();
296 status_ok!("Rendered", "{}", output_path.display());
297 }
298 status_ok!(
299 "Completed",
300 "{} packages rendered as HTML",
301 advisories_per_keyword.len()
302 );
303
304 let mut advisories_per_category = Vec::<AdvisoriesSubList>::new();
306 let mut categories = Vec::<(String, String, Option<usize>)>::new();
307 for (advisory, cdate, _) in advisories.clone() {
308 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
309 let advisory_title_type = title_type(&advisory);
310
311 for category in advisory.metadata.categories.as_slice() {
312 if !categories.iter().any(|(n, _, _)| n == category.name()) {
313 categories.push((
314 category.name().to_owned(),
315 format!("/categories/{}.html", category.name()),
316 None,
317 ));
318 }
319
320 match advisories_per_category
321 .iter_mut()
322 .find(|advisories| advisories.group_by == category.name())
323 {
324 Some(advisories) => advisories.advisories.push((
325 advisory.clone(),
326 cdate.clone(),
327 rendered_title.clone(),
328 advisory_title_type.clone(),
329 )),
330 None => advisories_per_category.push(AdvisoriesSubList {
331 title: format!("Advisories in category '{}'", category.name()),
332 group_by: category.name().to_string(),
333 advisories: vec![(
334 advisory.clone(),
335 cdate.clone(),
336 rendered_title.clone(),
337 advisory_title_type.clone(),
338 )],
339 }),
340 }
341 }
342 }
343 let folder = output_folder.join("categories");
344 fs::create_dir_all(&folder).unwrap();
345
346 render_list_index("Categories", categories, folder.as_ref());
348 for tpl in &advisories_per_category {
350 let output_path = folder.join(tpl.group_by.clone() + ".html");
351 fs::write(&output_path, tpl.render().unwrap()).unwrap();
352 status_ok!("Rendered", "{}", output_path.display());
353 }
354 status_ok!(
355 "Completed",
356 "{} packages rendered as HTML",
357 advisories_per_category.len() + 1
358 );
359
360 let index_path = output_folder.join("js").join("index.js");
362 render_index(&index_path, &advisories);
363 status_ok!("Rendered", "{}", index_path.display());
364 status_ok!(
365 "Completed",
366 "{} advisories rendered in search index as JS",
367 advisories.len()
368 );
369
370 let feed_path = output_folder.join("feed.xml");
372 let min_feed_len = 10;
373
374 #[allow(deprecated)]
376 let last_week_len = advisories
377 .iter()
378 .take_while(|(_, c, _)| {
379 Date::from_utc(
380 NaiveDate::parse_from_str(c.as_str(), "%Y-%m-%d").unwrap(),
381 Utc,
382 ) > Utc::today() - Duration::days(8)
383 })
384 .count();
385
386 let len = if advisories.len() < min_feed_len {
390 advisories.len()
391 } else if last_week_len > min_feed_len {
392 last_week_len
393 } else {
394 min_feed_len
395 };
396 render_feed(&feed_path, &advisories[..len]);
397 status_ok!("Rendered", "{}", feed_path.display());
398 status_ok!("Completed", "{} advisories rendered in atom feed", len);
399}
400
401fn title_type(advisory: &rustsec::Advisory) -> String {
403 use rustsec::advisory::Informational;
404
405 let id = advisory.id().as_str();
406 let package = advisory.metadata.package.as_str();
407
408 match &advisory.metadata.informational {
409 Some(Informational::Notice) => format!("{}: Security notice about {}", id, package),
410 Some(Informational::Unmaintained) => format!("{}: {} is unmaintained", id, package),
411 Some(Informational::Unsound) => format!("{}: Unsoundness in {}", id, package),
412 Some(Informational::Other(s)) => format!("{}: {} is {}", id, package, s),
413 Some(_) => format!("{}: Advisory for {}", id, package),
414 None => format!("{}: Vulnerability in {}", id, package),
416 }
417}
418
419fn render_index(
421 output_path: &Path,
422 advisories: &[(rustsec::Advisory, advisory::Date, advisory::Date)],
423) {
424 let mut ids: HashMap<String, Vec<Id>> = HashMap::new();
426 let mut packages = HashSet::new();
428
429 for (advisory, _, _) in advisories {
430 let id = advisory.id().to_owned();
431 for alias in advisory
432 .metadata
433 .aliases
434 .iter()
435 .chain(advisory.metadata.related.iter())
436 .chain(iter::once(&id))
437 {
438 let alias = alias.to_string().to_lowercase();
440 ids.entry(alias)
441 .and_modify(|v| v.push(id.clone()))
442 .or_insert_with(|| vec![id.clone()]);
443 }
444 packages.insert(advisory.metadata.package.to_string().to_lowercase());
445 }
446 let ids_json = serde_json::to_string(&ids).unwrap();
447 let package_json = serde_json::to_string(&packages).unwrap();
448
449 let js = format!("var ids = {}\nvar packages = {}\n", ids_json, package_json);
450 fs::write(output_path, js).unwrap();
451}
452
453fn render_feed(
455 output_path: &Path,
456 advisories: &[(rustsec::Advisory, advisory::Date, advisory::Date)],
457) {
458 let mut entries: Vec<Entry> = vec![];
459 let author = PersonBuilder::default().name("RustSec").build();
460
461 let latest_advisory_date =
463 advisories.first().unwrap().1.as_str().to_owned() + "T12:00:00+00:00";
464
465 for (advisory, cdate, mdate) in advisories {
466 let escaped_title_type = escape_str_attribute(&title_type(advisory)).into_owned();
467 let escaped_title = escape_str_attribute(advisory.title()).into_owned();
468 let cdate_time = cdate.as_str().to_owned() + "T12:00:00+00:00";
469 let mdate_time = mdate.as_str().to_owned() + "T12:00:00+00:00";
470 let url = "https://rustsec.org/advisories/".to_owned() + advisory.id().as_str() + ".html";
471
472 let link = LinkBuilder::default()
473 .rel("alternate")
474 .mime_type(Some("text/html".to_owned()))
475 .title(escaped_title_type.clone())
476 .href(url.clone())
477 .build();
478
479 let mut categories = vec![];
480 for category in &advisory.metadata.categories {
481 categories.push(
482 CategoryBuilder::default()
483 .term(category.to_string())
484 .build(),
485 );
486 }
487
488 let rendered_description =
489 markdown_to_html(advisory.description(), &ComrakOptions::default());
490 let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
491
492 let advisory_tmpl = AdvisoryContentTemplate {
493 advisory,
494 rendered_description,
495 rendered_title,
496 cdate: cdate.clone(),
497 mdate: mdate.clone(),
498 };
499 let html = advisory_tmpl.render().unwrap();
500 let content = ContentBuilder::default()
501 .content_type(Some("html".to_owned()))
502 .lang("en".to_owned())
503 .value(Some(html))
504 .build();
505
506 let mut summary = Text::plain(escaped_title);
507 summary.lang = Some("en".to_owned());
508
509 let item = EntryBuilder::default()
510 .id(url)
511 .title(escaped_title_type)
512 .summary(Some(summary))
513 .links(vec![link])
514 .categories(categories)
515 .published(Some(FixedDateTime::from_str(&cdate_time).unwrap()))
516 .updated(FixedDateTime::from_str(&mdate_time).unwrap())
518 .content(Some(content))
519 .build();
520 entries.push(item);
521 }
522
523 let self_url = "https://rustsec.org/feed.xml";
524 let alternate_link = LinkBuilder::default()
525 .href("https://rustsec.org/")
526 .rel("alternate")
527 .mime_type(Some("text/html".to_owned()))
528 .build();
529 let self_link = LinkBuilder::default()
530 .href(self_url)
531 .rel("self")
532 .mime_type(Some("application/atom+xml".to_owned()))
533 .build();
534
535 let mut subtitle = Text::plain("Security advisories filed against Rust crates".to_owned());
536 subtitle.lang = Some("en".to_owned());
537
538 let feed = FeedBuilder::default()
539 .id(self_url)
540 .title("RustSec Advisories")
541 .subtitle(Some(subtitle))
542 .links(vec![self_link, alternate_link])
543 .icon("https://rustsec.org/favicon.ico".to_owned())
544 .entries(entries)
545 .updated(FixedDateTime::from_str(&latest_advisory_date).unwrap())
546 .authors(vec![author])
547 .build();
548
549 let file = File::create(output_path).unwrap();
550 feed.write_to(file).unwrap();
551}
552
553#[derive(RustEmbed)]
554#[folder = "src/web/static/"]
555struct StaticAsset;
556
557fn copy_static_assets(output_folder: &Path) {
558 for file in StaticAsset::iter() {
559 let asset_path = PathBuf::from(file.as_ref());
560
561 if let Some(containing_folder) = asset_path.parent() {
563 fs::create_dir_all(output_folder.join(containing_folder)).unwrap();
564 }
565
566 let asset = StaticAsset::get(file.as_ref()).unwrap();
567 fs::write(output_folder.join(file.as_ref()), asset.data).unwrap();
568 }
569}
570
571mod filters {
572 use chrono::NaiveDate;
573 use rustsec::advisory;
574 use std::borrow::Borrow;
575
576 pub fn friendly_date<T: Borrow<advisory::Date>>(date: T) -> ::askama::Result<String> {
577 let date = date.borrow();
578
579 #[allow(deprecated)]
581 let date = NaiveDate::from_ymd(date.year().try_into().unwrap(), date.month(), date.day())
582 .format("%B %e, %Y")
583 .to_string();
584
585 Ok(date)
586 }
587
588 pub fn safe_keyword(s: &str) -> ::askama::Result<String> {
589 Ok(s.chars()
590 .map(|c| {
591 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
592 c
593 } else {
594 '-'
595 }
596 })
597 .collect())
598 }
599}
600
601fn advisory_dates(
602 advisory: &rustsec::Advisory,
603 repo: &Repository,
604 mod_times: &GitModificationTimes,
605) -> (advisory::Date, advisory::Date) {
606 let relative_path = format!(
607 "{}/{}/{}.md",
608 advisory.metadata.collection.unwrap(),
609 advisory.metadata.package,
610 advisory.id()
611 );
612 let relative_path = Path::new(&relative_path);
613 let mdate = mod_times.mdate_for_path(GitPath::new(repo, relative_path).unwrap());
614 let cdate = mod_times.cdate_for_path(GitPath::new(repo, relative_path).unwrap());
615 (cdate, mdate)
616}