cargo-sonar 1.6.0

Helper to transform reports from Rust tooling for code quality, into valid Sonar report
Documentation
use crate::{cargo::Lockfile, Category, Location, Severity};
use dyn_iter::{DynIter, IntoDynIterator as _};
use eyre::Result;
use std::io::{BufRead as _, BufReader};
use tracing::warn;

const DENY_ENGINE: &str = "deny";

#[derive(Debug, serde::Deserialize)]
// ALLOW: Unused fields are part of the deserialized schema from 'cargo-deny'
#[allow(dead_code)]
pub struct GraphNode {
    name: String,
    version: String,
    #[serde(default)]
    kind: String,
    #[serde(default)]
    repeat: bool,
    #[serde(default)]
    parents: Vec<GraphNode>,
}

#[derive(Debug, serde::Deserialize)]
// ALLOW: Unused fields are part of the deserialized schema from 'cargo-deny'
#[allow(dead_code)]
pub struct Label {
    line: usize,
    column: usize,
    message: String,
    span: String,
}

type AdvisoryReport = rustsec::advisory::Metadata;

#[derive(Debug, serde::Deserialize)]
pub struct LicenseReport {
    code: String,
    graphs: Vec<GraphNode>,
    message: String,
    labels: Vec<Label>,
}

#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum Report {
    Advisory { advisory: Box<AdvisoryReport> },
    License(Box<LicenseReport>),
}

pub struct Deny<'lock> {
    issues: DynIter<'lock, Issue<'lock>>,
}

impl<'lock> Deny<'lock> {
    /// Create a Deny parser for issues
    ///
    /// # Errors
    /// May fail reading and parsing the file (IO errors).
    #[inline]
    pub fn try_new<R>(json_read: R, lockfile: &'lock Lockfile) -> Result<Self>
    where
        R: std::io::Read + 'static,
    {
        let reader = BufReader::new(json_read);
        let issues = reader
            .lines()
            .map_while(Result::ok)
            .flat_map(|line| serde_json::from_str::<serde_json::Value>(&line))
            .filter_map(|value| value.get("fields").map(ToString::to_string))
            .filter_map(move |s| match serde_json::from_str::<Report>(&s) {
                Ok(deny_report) => Some((lockfile, deny_report)),
                Err(e) => {
                    warn!("failed to deserialize '{s}': {e}");
                    None
                }
            })
            .into_dyn_iter();
        let deny = Self { issues };
        Ok(deny)
    }
}

impl<'lock> Iterator for Deny<'lock> {
    type Item = Issue<'lock>;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.issues.next()
    }
}

pub type Issue<'lock> = (&'lock Lockfile, Report);

impl crate::Issue for Issue<'_> {
    #[inline]
    fn analyzer_id(&self) -> String {
        DENY_ENGINE.to_owned()
    }

    #[inline]
    fn issue_id(&self) -> String {
        match self.1 {
            Report::Advisory { ref advisory } => advisory.id.to_string(),
            Report::License(ref license) => license.code.clone(),
        }
    }

    #[inline]
    fn fingerprint(&self) -> md5::Digest {
        match self.1 {
            Report::Advisory { ref advisory } => {
                md5::compute(format!("{}:{}", advisory.package, advisory.id))
            }
            Report::License(ref license) => md5::compute(license.code.clone()),
        }
    }

    #[inline]
    fn category(&self) -> Category {
        match self.1 {
            Report::Advisory { .. } => Category::Security,
            Report::License(_) => Category::Style,
        }
    }

    #[inline]
    fn severity(&self) -> Severity {
        match self.1 {
            Report::Advisory { .. } => Severity::Major,
            Report::License(_) => Severity::Info,
        }
    }

    #[inline]
    fn location(&self) -> Option<Location> {
        let crate_name = match self.1 {
            Report::Advisory { ref advisory } => advisory.package.as_str().to_owned(),
            Report::License(ref license) => license.graphs.first()?.name.as_str().to_owned(),
        };
        let message = match self.1 {
            Report::Advisory { ref advisory } => format!(
                "{} (see https://github.com/rustsec/advisory-db/blob/main/crates/{}/{}.md)",
                advisory.title, advisory.package, advisory.id
            ),
            Report::License(ref license) => license
                .labels
                .iter()
                .fold(license.message.clone(), |message, label| {
                    format!("{}.\n`{}` {}", message, label.span, label.message)
                }),
        };
        let location = Location {
            path: self.0.lockfile_path.clone(),
            range: self.0.dependency_range(&crate_name),
            message,
        };
        Some(location)
    }
}

#[cfg(test)]
mod tests {
    use crate::{
        Category, Issue as _, Severity, TextRange,
        {cargo::PackageRange, deny::Deny, Lockfile},
    };
    use std::{io::Write as _, path::PathBuf};
    use test_log::test;

    #[test]
    fn single_issue() {
        let json = r#"{
          "fields": {
            "code": "B004",
            "graphs": [
              {
                "name": "tracing-subscriber",
                "parents": [
                  {
                    "name": "tracing-error",
                    "parents": [
                      {
                        "name": "color-eyre",
                        "parents": [
                          {
                            "name": "cargo-sonar",
                            "version": "0.8.1"
                          }
                        ],
                        "version": "0.5.11"
                      },
                      {
                        "name": "color-spantrace",
                        "parents": [
                          {
                            "name": "color-eyre",
                            "repeat": true,
                            "version": "0.5.11"
                          }
                        ],
                        "version": "0.1.6"
                      }
                    ],
                    "version": "0.1.2"
                  }
                ],
                "version": "0.2.25"
              },
              {
                "name": "tracing-subscriber",
                "parents": [
                  {
                    "name": "cargo-sonar",
                    "version": "0.8.1"
                  }
                ],
                "version": "0.3.2"
              }
            ],
            "labels": [
              {
                "column": 1,
                "line": 93,
                "message": "lock entries",
                "span": "tracing-subscriber 0.2.25 registry+https://github.com/rust-lang/crates.io-index\ntracing-subscriber 0.3.2 registry+https://github.com/rust-lang/crates.io-index"
              }
            ],
            "message": "found 2 duplicate entries for crate 'tracing-subscriber'",
            "severity": "warning"
          },
          "type": "diagnostic"
        }"#;
        let json = json.to_owned().replace('\n', "");
        let mut deny_json = tempfile::NamedTempFile::new().unwrap();
        write!(deny_json, "{}", json).unwrap();
        let deny_json = deny_json.reopen().unwrap();

        let lockfile = Lockfile {
            lockfile_path: PathBuf::from("Cargo.lock"),
            dependencies: [(
                "tracing-subscriber".to_owned(),
                PackageRange {
                    range: TextRange::new((935, 1), (951, 2)),
                    name_range: TextRange::new((936, 9), (936, 26)),
                    version_range: TextRange::new((937, 12), (937, 17)),
                },
            )]
            .into_iter()
            .collect(),
        };

        let mut deny = Deny::try_new(deny_json, &lockfile).unwrap();
        let issue = deny.next().unwrap();
        assert_eq!(issue.analyzer_id(), "deny");
        assert_eq!(issue.issue_uid(), "deny::B004");
        assert!(matches!(issue.severity(), Severity::Info));
        assert!(matches!(issue.category(), Category::Style));
        let location = issue.location().unwrap();
        assert_eq!(location.path, PathBuf::from("Cargo.lock"));
        assert_eq!(location.message, "found 2 duplicate entries for crate 'tracing-subscriber'.\n`tracing-subscriber 0.2.25 registry+https://github.com/rust-lang/crates.io-index\ntracing-subscriber 0.3.2 registry+https://github.com/rust-lang/crates.io-index` lock entries");
        assert_eq!(location.range.start.line, 935);
        assert_eq!(location.range.end.line, 951);
        assert_eq!(location.range.start.column, 1);
        assert_eq!(location.range.end.column, 2);
    }

    #[test]
    fn parsing_deny() {
        let lockfile = Lockfile {
            lockfile_path: PathBuf::from("tests/fixtures/Cargo.lock"),
            dependencies: [].into_iter().collect(),
        };

        let deny = Deny::try_new(
            std::fs::File::open("tests/fixtures/deny-2.json").unwrap(),
            &lockfile,
        )
        .unwrap();
        let issues = deny.collect::<Vec<_>>();
        assert_eq!(issues.len(), 1);
    }
}