Skip to main content

rustsec/advisory/
linter.rs

1//! Advisory linter: ensure advisories are well-formed according to the
2//! currently valid set of fields.
3//!
4//! This is run in CI at the time advisories are submitted.
5
6use super::{Advisory, Category, parts};
7use crate::advisory::license::License;
8use crate::fs;
9use std::str::FromStr;
10use std::{fmt, path::Path};
11
12/// Lint information about a particular advisory
13#[derive(Debug)]
14pub struct Linter {
15    /// Advisory being linted
16    advisory: Advisory,
17
18    /// Errors detected during linting
19    errors: Vec<Error>,
20}
21
22impl Linter {
23    /// Lint the advisory TOML file located at the given path
24    pub fn lint_file<P: AsRef<Path>>(path: P) -> Result<Self, crate::Error> {
25        let path = path.as_ref();
26
27        match path.extension().and_then(|ext| ext.to_str()) {
28            Some("md") => (),
29            other => fail!(
30                crate::ErrorKind::Parse,
31                "invalid advisory file extension: {}",
32                other.unwrap_or("(missing)")
33            ),
34        }
35
36        let advisory_data = fs::read_to_string(path).map_err(|e| {
37            crate::Error::with_source(
38                crate::ErrorKind::Io,
39                format!("couldn't open {}", path.display()),
40                e,
41            )
42        })?;
43
44        Self::lint_string(&advisory_data)
45    }
46
47    /// Lint the given advisory data
48    pub fn lint_string(s: &str) -> Result<Self, crate::Error> {
49        // Ensure the advisory parses according to the normal parser first
50        let advisory = s.parse::<Advisory>()?;
51
52        // Get advisory "front matter" (TOML formatted)
53        let advisory_parts = parts::Parts::parse(s)?;
54        let front_matter = advisory_parts
55            .front_matter
56            .parse::<toml::Table>()
57            .map_err(crate::Error::from_toml)?;
58
59        let mut linter = Self {
60            advisory,
61            errors: vec![],
62        };
63
64        linter.lint_advisory(&front_matter);
65        Ok(linter)
66    }
67
68    /// Get the parsed advisory
69    pub fn advisory(&self) -> &Advisory {
70        &self.advisory
71    }
72
73    /// Get the errors that occurred during linting
74    pub fn errors(&self) -> &[Error] {
75        self.errors.as_slice()
76    }
77
78    /// Lint the provided TOML value as the toplevel table of an advisory
79    fn lint_advisory(&mut self, advisory: &toml::Table) {
80        for (key, value) in advisory {
81            match key.as_str() {
82                "advisory" => self.lint_metadata(value),
83                "versions" => self.lint_versions(value),
84                "affected" => self.lint_affected(value),
85                _ => self.errors.push(Error {
86                    kind: ErrorKind::key(key),
87                    section: None,
88                    message: None,
89                }),
90            }
91        }
92    }
93
94    /// Lint the `[advisory]` metadata section
95    fn lint_metadata(&mut self, metadata: &toml::Value) {
96        let mut year = None;
97
98        if let Some(table) = metadata.as_table() {
99            for (key, value) in table {
100                match key.as_str() {
101                    "id" => {
102                        if self.advisory.metadata.id.is_other() {
103                            self.errors.push(Error {
104                                kind: ErrorKind::value("id", value.to_string()),
105                                section: Some("advisory"),
106                                message: Some("unknown advisory ID type"),
107                            });
108                        } else if let Some(y1) = self.advisory.metadata.id.year() {
109                            // Exclude CVE IDs, since the year from CVE ID may not match the report date
110                            if !self.advisory.metadata.id.is_cve() {
111                                if let Some(y2) = year {
112                                    if y1 != y2 {
113                                        self.errors.push(Error {
114                                            kind: ErrorKind::value("id", value.to_string()),
115                                            section: Some("advisory"),
116                                            message: Some(
117                                                "year in advisory ID does not match date",
118                                            ),
119                                        });
120                                    }
121                                } else {
122                                    year = Some(y1);
123                                }
124                            }
125                        }
126                    }
127                    "categories" => {
128                        for category in &self.advisory.metadata.categories {
129                            if let Category::Other(other) = category {
130                                self.errors.push(Error {
131                                    kind: ErrorKind::value("category", other.to_string()),
132                                    section: Some("advisory"),
133                                    message: Some("unknown category"),
134                                });
135                            }
136                        }
137                    }
138                    "collection" => self.errors.push(Error {
139                        kind: ErrorKind::Malformed,
140                        section: Some("advisory"),
141                        message: Some("collection shouldn't be explicit; inferred by location"),
142                    }),
143                    "informational" => {
144                        let informational = self
145                            .advisory
146                            .metadata
147                            .informational
148                            .as_ref()
149                            .expect("parsed informational");
150
151                        if informational.is_other() {
152                            self.errors.push(Error {
153                                kind: ErrorKind::value("informational", informational.as_str()),
154                                section: Some("advisory"),
155                                message: Some("unknown informational advisory type"),
156                            });
157                        }
158                    }
159                    "url" => {
160                        if let Some(url) = value.as_str() {
161                            if !url.starts_with("https://") {
162                                self.errors.push(Error {
163                                    kind: ErrorKind::value("url", value.to_string()),
164                                    section: Some("advisory"),
165                                    message: Some("URL must start with https://"),
166                                });
167                            }
168                        }
169                    }
170                    "date" => {
171                        let y1 = self.advisory.metadata.date.year();
172
173                        if let Some(y2) = year {
174                            if y1 != y2 {
175                                self.errors.push(Error {
176                                    kind: ErrorKind::value("date", value.to_string()),
177                                    section: Some("advisory"),
178                                    message: Some("year in advisory ID does not match date"),
179                                });
180                            }
181                        } else {
182                            year = Some(y1);
183                        }
184                    }
185                    "yanked" => {
186                        if self.advisory.metadata.withdrawn.is_none() {
187                            self.errors.push(Error {
188                                kind: ErrorKind::Malformed,
189                                section: Some("metadata"),
190                                message: Some(
191                                    "Field `yanked` is deprecated, use `withdrawn` field instead",
192                                ),
193                            });
194                        }
195                    }
196                    "license" => {
197                        if let Some(l) = value.as_str() {
198                            // We don't want to accept any license, only explicitly accepted ones
199                            let unknown_license =
200                                matches!(License::from_str(l).unwrap(), License::Other(_));
201                            if unknown_license {
202                                self.errors.push(Error {
203                                    kind: ErrorKind::value("license", l.to_string()),
204                                    section: Some("advisory"),
205                                    message: Some("Unknown license"),
206                                });
207                            }
208                        }
209                    }
210                    "aliases" | "cvss" | "keywords" | "package" | "references" | "related"
211                    | "title" | "withdrawn" | "description" | "expect-deleted" => (),
212                    _ => self.errors.push(Error {
213                        kind: ErrorKind::key(key),
214                        section: Some("advisory"),
215                        message: None,
216                    }),
217                }
218            }
219        } else {
220            self.errors.push(Error {
221                kind: ErrorKind::Malformed,
222                section: Some("advisory"),
223                message: Some("expected table"),
224            });
225        }
226    }
227
228    /// Lint the `[versions]` section of an advisory
229    fn lint_versions(&mut self, versions: &toml::Value) {
230        if let Some(table) = versions.as_table() {
231            for (key, _) in table {
232                match key.as_str() {
233                    "patched" | "unaffected" => (),
234                    _ => self.errors.push(Error {
235                        kind: ErrorKind::key(key),
236                        section: Some("versions"),
237                        message: None,
238                    }),
239                }
240            }
241        }
242    }
243
244    /// Lint the `[affected]` section of an advisory
245    fn lint_affected(&mut self, affected: &toml::Value) {
246        if let Some(table) = affected.as_table() {
247            for (key, _) in table {
248                match key.as_str() {
249                    "functions" => {
250                        for function in self.advisory.affected.as_ref().unwrap().functions.keys() {
251                            // Rust identifiers do not allow '-' character but crate names do,
252                            // thus "crate-name" would be addressed as "crate_name" in function path
253                            let crate_name =
254                                self.advisory.metadata.package.as_str().replace('-', "_");
255                            if function.segments()[0].as_str() != crate_name {
256                                self.errors.push(Error {
257                                    kind: ErrorKind::value("functions", function.to_string()),
258                                    section: Some("affected"),
259                                    message: Some("function path must start with crate name"),
260                                });
261                            }
262                        }
263                    }
264                    "arch" | "os" => (),
265                    _ => self.errors.push(Error {
266                        kind: ErrorKind::key(key),
267                        section: Some("affected"),
268                        message: None,
269                    }),
270                }
271            }
272        }
273    }
274}
275
276/// Lint errors
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct Error {
279    /// Kind of error
280    kind: ErrorKind,
281
282    /// Section of the advisory where the error occurred
283    section: Option<&'static str>,
284
285    /// Message about why it's invalid
286    message: Option<&'static str>,
287}
288
289impl Error {
290    /// Get the kind of error
291    pub fn kind(&self) -> &ErrorKind {
292        &self.kind
293    }
294
295    /// Get the section of the advisory where the error occurred
296    pub fn section(&self) -> Option<&str> {
297        self.section.as_ref().map(AsRef::as_ref)
298    }
299
300    /// Get an optional message about the lint failure
301    pub fn message(&self) -> Option<&str> {
302        self.message.as_ref().map(AsRef::as_ref)
303    }
304}
305
306impl fmt::Display for Error {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        write!(f, "{}", &self.kind)?;
309
310        if let Some(section) = &self.section {
311            write!(f, " in [{section}]")?;
312        } else {
313            write!(f, " in toplevel")?;
314        }
315
316        if let Some(msg) = &self.message {
317            write!(f, ": {msg}")?
318        }
319
320        Ok(())
321    }
322}
323
324/// Lint errors
325#[derive(Clone, Debug, Eq, PartialEq)]
326#[non_exhaustive]
327pub enum ErrorKind {
328    /// Advisory is structurally malformed
329    Malformed,
330
331    /// Unknown key
332    InvalidKey {
333        /// Name of the key
334        name: String,
335    },
336
337    /// Unknown value
338    InvalidValue {
339        /// Name of the key
340        name: String,
341
342        /// Invalid value
343        value: String,
344    },
345}
346
347impl ErrorKind {
348    /// Invalid key
349    pub fn key(name: &str) -> Self {
350        ErrorKind::InvalidKey {
351            name: name.to_owned(),
352        }
353    }
354
355    /// Invalid value
356    pub fn value(name: &str, value: impl Into<String>) -> Self {
357        ErrorKind::InvalidValue {
358            name: name.to_owned(),
359            value: value.into(),
360        }
361    }
362}
363
364impl fmt::Display for ErrorKind {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        match self {
367            ErrorKind::Malformed => write!(f, "malformed content"),
368            ErrorKind::InvalidKey { name } => write!(f, "invalid key `{name}`"),
369            ErrorKind::InvalidValue { name, value } => {
370                write!(f, "invalid value `{value}` for key `{name}`")
371            }
372        }
373    }
374}