| //! Advisory linter: ensure advisories are well-formed according to the |
| //! currently valid set of fields. |
| //! |
| //! This is run in CI at the time advisories are submitted. |
| |
| use super::{parts, Advisory, Category}; |
| use crate::advisory::license::License; |
| use crate::fs; |
| use std::str::FromStr; |
| use std::{fmt, path::Path}; |
| |
| /// Lint information about a particular advisory |
| #[derive(Debug)] |
| pub struct Linter { |
| /// Advisory being linted |
| advisory: Advisory, |
| |
| /// Errors detected during linting |
| errors: Vec<Error>, |
| } |
| |
| impl Linter { |
| /// Lint the advisory TOML file located at the given path |
| pub fn lint_file<P: AsRef<Path>>(path: P) -> Result<Self, crate::Error> { |
| let path = path.as_ref(); |
| |
| match path.extension().and_then(|ext| ext.to_str()) { |
| Some("md") => (), |
| other => fail!( |
| crate::ErrorKind::Parse, |
| "invalid advisory file extension: {}", |
| other.unwrap_or("(missing)") |
| ), |
| } |
| |
| let advisory_data = fs::read_to_string(path).map_err(|e| { |
| format_err!( |
| crate::ErrorKind::Io, |
| "couldn't open {}: {}", |
| path.display(), |
| e |
| ) |
| })?; |
| |
| Self::lint_string(&advisory_data) |
| } |
| |
| /// Lint the given advisory data |
| pub fn lint_string(s: &str) -> Result<Self, crate::Error> { |
| // Ensure the advisory parses according to the normal parser first |
| let advisory = s.parse::<Advisory>()?; |
| |
| // Get advisory "front matter" (TOML formatted) |
| let advisory_parts = parts::Parts::parse(s)?; |
| let front_matter = advisory_parts |
| .front_matter |
| .parse::<toml::Value>() |
| .map_err(crate::Error::from_toml)?; |
| |
| let mut linter = Self { |
| advisory, |
| errors: vec![], |
| }; |
| |
| linter.lint_advisory(&front_matter); |
| Ok(linter) |
| } |
| |
| /// Get the parsed advisory |
| pub fn advisory(&self) -> &Advisory { |
| &self.advisory |
| } |
| |
| /// Get the errors that occurred during linting |
| pub fn errors(&self) -> &[Error] { |
| self.errors.as_slice() |
| } |
| |
| /// Lint the provided TOML value as the toplevel table of an advisory |
| fn lint_advisory(&mut self, advisory: &toml::Value) { |
| if let Some(table) = advisory.as_table() { |
| for (key, value) in table { |
| match key.as_str() { |
| "advisory" => self.lint_metadata(value), |
| "versions" => self.lint_versions(value), |
| "affected" => self.lint_affected(value), |
| _ => self.errors.push(Error { |
| kind: ErrorKind::key(key), |
| section: None, |
| message: None, |
| }), |
| } |
| } |
| } else { |
| self.errors.push(Error { |
| kind: ErrorKind::Malformed, |
| section: None, |
| message: Some("expected table"), |
| }); |
| } |
| } |
| |
| /// Lint the `[advisory]` metadata section |
| fn lint_metadata(&mut self, metadata: &toml::Value) { |
| let mut year = None; |
| |
| if let Some(table) = metadata.as_table() { |
| for (key, value) in table { |
| match key.as_str() { |
| "id" => { |
| if self.advisory.metadata.id.is_other() { |
| self.errors.push(Error { |
| kind: ErrorKind::value("id", value.to_string()), |
| section: Some("advisory"), |
| message: Some("unknown advisory ID type"), |
| }); |
| } else if let Some(y1) = self.advisory.metadata.id.year() { |
| // Exclude CVE IDs, since the year from CVE ID may not match the report date |
| if !self.advisory.metadata.id.is_cve() { |
| if let Some(y2) = year { |
| if y1 != y2 { |
| self.errors.push(Error { |
| kind: ErrorKind::value("id", value.to_string()), |
| section: Some("advisory"), |
| message: Some( |
| "year in advisory ID does not match date", |
| ), |
| }); |
| } |
| } else { |
| year = Some(y1); |
| } |
| } |
| } |
| } |
| "categories" => { |
| for category in &self.advisory.metadata.categories { |
| if let Category::Other(other) = category { |
| self.errors.push(Error { |
| kind: ErrorKind::value("category", other.to_string()), |
| section: Some("advisory"), |
| message: Some("unknown category"), |
| }); |
| } |
| } |
| } |
| "collection" => self.errors.push(Error { |
| kind: ErrorKind::Malformed, |
| section: Some("advisory"), |
| message: Some("collection shouldn't be explicit; inferred by location"), |
| }), |
| "informational" => { |
| let informational = self |
| .advisory |
| .metadata |
| .informational |
| .as_ref() |
| .expect("parsed informational"); |
| |
| if informational.is_other() { |
| self.errors.push(Error { |
| kind: ErrorKind::value("informational", informational.as_str()), |
| section: Some("advisory"), |
| message: Some("unknown informational advisory type"), |
| }); |
| } |
| } |
| "url" => { |
| if let Some(url) = value.as_str() { |
| if !url.starts_with("https://") { |
| self.errors.push(Error { |
| kind: ErrorKind::value("url", value.to_string()), |
| section: Some("advisory"), |
| message: Some("URL must start with https://"), |
| }); |
| } |
| } |
| } |
| "date" => { |
| let y1 = self.advisory.metadata.date.year(); |
| |
| if let Some(y2) = year { |
| if y1 != y2 { |
| self.errors.push(Error { |
| kind: ErrorKind::value("date", value.to_string()), |
| section: Some("advisory"), |
| message: Some("year in advisory ID does not match date"), |
| }); |
| } |
| } else { |
| year = Some(y1); |
| } |
| } |
| "yanked" => { |
| if self.advisory.metadata.withdrawn.is_none() { |
| self.errors.push(Error { |
| kind: ErrorKind::Malformed, |
| section: Some("metadata"), |
| message: Some( |
| "Field `yanked` is deprecated, use `withdrawn` field instead", |
| ), |
| }); |
| } |
| } |
| "license" => { |
| if let Some(l) = value.as_str() { |
| // We don't want to accept any license, only explicitly accepted ones |
| let unknown_license = |
| matches!(License::from_str(l).unwrap(), License::Other(_)); |
| if unknown_license { |
| self.errors.push(Error { |
| kind: ErrorKind::value("license", l.to_string()), |
| section: Some("advisory"), |
| message: Some("Unknown license"), |
| }); |
| } |
| } |
| } |
| "aliases" | "cvss" | "keywords" | "package" | "references" | "related" |
| | "title" | "withdrawn" | "description" => (), |
| _ => self.errors.push(Error { |
| kind: ErrorKind::key(key), |
| section: Some("advisory"), |
| message: None, |
| }), |
| } |
| } |
| } else { |
| self.errors.push(Error { |
| kind: ErrorKind::Malformed, |
| section: Some("advisory"), |
| message: Some("expected table"), |
| }); |
| } |
| } |
| |
| /// Lint the `[versions]` section of an advisory |
| fn lint_versions(&mut self, versions: &toml::Value) { |
| if let Some(table) = versions.as_table() { |
| for (key, _) in table { |
| match key.as_str() { |
| "patched" | "unaffected" => (), |
| _ => self.errors.push(Error { |
| kind: ErrorKind::key(key), |
| section: Some("versions"), |
| message: None, |
| }), |
| } |
| } |
| } |
| } |
| |
| /// Lint the `[affected]` section of an advisory |
| fn lint_affected(&mut self, affected: &toml::Value) { |
| if let Some(table) = affected.as_table() { |
| for (key, _) in table { |
| match key.as_str() { |
| "functions" => { |
| for function in self.advisory.affected.as_ref().unwrap().functions.keys() { |
| // Rust identifiers do not allow '-' character but crate names do, |
| // thus "crate-name" would be addressed as "crate_name" in function path |
| let crate_name = |
| self.advisory.metadata.package.as_str().replace('-', "_"); |
| if function.segments()[0].as_str() != crate_name { |
| self.errors.push(Error { |
| kind: ErrorKind::value("functions", function.to_string()), |
| section: Some("affected"), |
| message: Some("function path must start with crate name"), |
| }); |
| } |
| } |
| } |
| "arch" | "os" => (), |
| _ => self.errors.push(Error { |
| kind: ErrorKind::key(key), |
| section: Some("affected"), |
| message: None, |
| }), |
| } |
| } |
| } |
| } |
| } |
| |
| /// Lint errors |
| #[derive(Clone, Debug, Eq, PartialEq)] |
| pub struct Error { |
| /// Kind of error |
| kind: ErrorKind, |
| |
| /// Section of the advisory where the error occurred |
| section: Option<&'static str>, |
| |
| /// Message about why it's invalid |
| message: Option<&'static str>, |
| } |
| |
| impl Error { |
| /// Get the kind of error |
| pub fn kind(&self) -> &ErrorKind { |
| &self.kind |
| } |
| |
| /// Get the section of the advisory where the error occurred |
| pub fn section(&self) -> Option<&str> { |
| self.section.as_ref().map(AsRef::as_ref) |
| } |
| |
| /// Get an optional message about the lint failure |
| pub fn message(&self) -> Option<&str> { |
| self.message.as_ref().map(AsRef::as_ref) |
| } |
| } |
| |
| impl fmt::Display for Error { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| write!(f, "{}", &self.kind)?; |
| |
| if let Some(section) = &self.section { |
| write!(f, " in [{}]", section)?; |
| } else { |
| write!(f, " in toplevel")?; |
| } |
| |
| if let Some(msg) = &self.message { |
| write!(f, ": {}", msg)? |
| } |
| |
| Ok(()) |
| } |
| } |
| |
| /// Lint errors |
| #[derive(Clone, Debug, Eq, PartialEq)] |
| #[non_exhaustive] |
| pub enum ErrorKind { |
| /// Advisory is structurally malformed |
| Malformed, |
| |
| /// Unknown key |
| InvalidKey { |
| /// Name of the key |
| name: String, |
| }, |
| |
| /// Unknown value |
| InvalidValue { |
| /// Name of the key |
| name: String, |
| |
| /// Invalid value |
| value: String, |
| }, |
| } |
| |
| impl ErrorKind { |
| /// Invalid key |
| pub fn key(name: &str) -> Self { |
| ErrorKind::InvalidKey { |
| name: name.to_owned(), |
| } |
| } |
| |
| /// Invalid value |
| pub fn value(name: &str, value: impl Into<String>) -> Self { |
| ErrorKind::InvalidValue { |
| name: name.to_owned(), |
| value: value.into(), |
| } |
| } |
| } |
| |
| impl fmt::Display for ErrorKind { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| match self { |
| ErrorKind::Malformed => write!(f, "malformed content"), |
| ErrorKind::InvalidKey { name } => write!(f, "invalid key `{}`", name), |
| ErrorKind::InvalidValue { name, value } => { |
| write!(f, "invalid value `{}` for key `{}`", value, name) |
| } |
| } |
| } |
| } |