Skip to main content

cargo_sonar/
udeps.rs

1use crate::{cargo::Lockfile, Category, Location, Severity};
2use dyn_iter::{DynIter, IntoDynIterator as _};
3use eyre::{Context as _, Result};
4use md5::Digest;
5use std::collections::{BTreeMap, BTreeSet};
6
7const UDEPS_ENGINE: &str = "udeps";
8
9#[derive(Debug, Clone, Copy, strum::Display)]
10#[non_exhaustive]
11pub enum DependencyType {
12    #[strum(serialize = "normal")]
13    Normal,
14    #[strum(serialize = "development")]
15    Development,
16    #[strum(serialize = "build")]
17    Build,
18}
19
20#[derive(Debug, serde::Deserialize)]
21// ALLOW: Unused fields are part of the deserialized schema from 'cargo-udeps'
22#[allow(dead_code)]
23struct OutcomeUnusedDeps {
24    manifest_path: String,
25    normal: BTreeSet<String>,
26    development: BTreeSet<String>,
27    build: BTreeSet<String>,
28}
29
30impl OutcomeUnusedDeps {
31    fn dependencies(self) -> impl Iterator<Item = (DependencyType, DependencyName)> {
32        self.normal
33            .into_iter()
34            .map(|normal_dep| (DependencyType::Normal, normal_dep))
35            .chain(
36                self.development
37                    .into_iter()
38                    .map(|development_dep| (DependencyType::Development, development_dep)),
39            )
40            .chain(
41                self.build
42                    .into_iter()
43                    .map(|build_dep| (DependencyType::Build, build_dep)),
44            )
45    }
46}
47
48#[derive(Debug, serde::Deserialize)]
49// ALLOW: Unused fields are part of the deserialized schema from 'cargo-udeps'
50#[allow(dead_code)]
51struct Outcome {
52    success: bool,
53    unused_deps: BTreeMap<String, OutcomeUnusedDeps>,
54    note: Option<String>,
55}
56
57pub struct Udeps<'lock> {
58    issues: DynIter<'lock, Issue<'lock>>,
59}
60
61impl<'lock> Iterator for Udeps<'lock> {
62    type Item = Issue<'lock>;
63
64    #[inline]
65    fn next(&mut self) -> Option<Self::Item> {
66        self.issues.next()
67    }
68}
69
70impl<'lock> Udeps<'lock> {
71    /// Create a Udeps parser for issues
72    ///
73    /// # Errors
74    /// May fail reading and parsing the file (IO errors).
75    #[inline]
76    pub fn try_new<R>(json_read: R, lockfile: &'lock Lockfile) -> Result<Self>
77    where
78        R: std::io::Read + 'static,
79    {
80        let outcome = serde_json::from_reader::<_, Outcome>(json_read).with_context(|| {
81            format!(
82                "failed to be parsed as a '{}'",
83                std::any::type_name::<Outcome>(),
84            )
85        })?;
86        let issues = outcome
87            .unused_deps
88            .into_iter()
89            .filter_map(move |(package_id, outcome_unused)| {
90                package_id
91                    .split_ascii_whitespace()
92                    .next()
93                    .map(str::to_owned)
94                    .map(move |package_name| {
95                        outcome_unused.dependencies().map(move |(dep_type, dep)| {
96                            (lockfile, package_name.clone(), dep_type, dep.clone())
97                        })
98                    })
99            })
100            .flatten()
101            .into_dyn_iter();
102        let udeps = Self { issues };
103        Ok(udeps)
104    }
105}
106
107pub type PackageName = String;
108pub type DependencyName = String;
109pub type Issue<'lock> = (&'lock Lockfile, PackageName, DependencyType, DependencyName);
110
111impl crate::Issue for Issue<'_> {
112    #[inline]
113    fn analyzer_id(&self) -> String {
114        UDEPS_ENGINE.to_owned()
115    }
116    #[inline]
117    fn issue_id(&self) -> String {
118        format!("{}::{}", self.2, self.3)
119    }
120    #[inline]
121    fn fingerprint(&self) -> Digest {
122        md5::compute(format!("{}::{}::{}", self.1, self.2, self.3))
123    }
124    #[inline]
125    fn category(&self) -> Category {
126        Category::Security
127    }
128    #[inline]
129    fn severity(&self) -> Severity {
130        Severity::Minor
131    }
132    #[inline]
133    fn location(&self) -> Option<Location> {
134        let message = format!(
135            "Dependency '{}' is unused as a {} dependency in package '{}'",
136            self.3, self.2, self.1,
137        );
138        let path = self.0.lockfile_path.clone();
139        let range = self.0.dependency_range(&self.3);
140        let location = Location {
141            path,
142            range,
143            message,
144        };
145        Some(location)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use crate::{udeps::Udeps, Category, Issue as _, Severity};
152    use std::io::Write as _;
153    use test_log::test;
154
155    #[test]
156    fn single_issue() {
157        let json = r#"{
158          "success": false,
159          "unused_deps": {
160            "useless 0.1.0 (path+file:///tmp/useless)": {
161              "manifest_path": "/tmp/useless/Cargo.toml",
162              "normal": [
163                "if_chain"
164              ],
165              "development": [],
166              "build": []
167            }
168          },
169          "note": "Note: They might be false-positive.\n      For example, `cargo-udeps` cannot detect usage of crates that are only used in doc-tests.\n
170              To ignore some dependencies, write `package.metadata.cargo-udeps.ignore` in Cargo.toml.\n"
171        }"#;
172        let json = json.to_owned().replace('\n', "");
173        let mut udeps_json = tempfile::NamedTempFile::new().unwrap();
174        write!(udeps_json, "{}", json).unwrap();
175        let udeps_json = udeps_json.reopen().unwrap();
176
177        let cargo_lock = r#"
178            # This file is automatically @generated by Cargo.
179            # It is not intended for manual editing.
180            version = 3
181
182            [[package]]
183            name = "useless"
184            version = "0.1.0"
185            dependencies = [
186             "if_chain",
187            ]
188
189            [[package]]
190            name = "if_chain"
191            version = "1.0.0"
192            source = "registry+https://github.com/rust-lang/crates.io-index"
193            checksum = "c3360c7b59e5ffa2653671fb74b4741a5d343c03f331c0a4aeda42b5c2b0ec7d"
194        "#;
195        let mut cargo_lock_toml = tempfile::NamedTempFile::new().unwrap();
196        write!(cargo_lock_toml, "{}", cargo_lock).unwrap();
197
198        let lockfile = crate::cargo::Lockfile::try_from(cargo_lock_toml.path()).unwrap();
199
200        let mut udeps = Udeps::try_new(udeps_json, &lockfile).unwrap();
201        let issue = udeps.next().unwrap();
202        assert_eq!(issue.analyzer_id(), "udeps");
203        assert_eq!(issue.issue_uid(), "udeps::normal::if_chain");
204        assert!(matches!(issue.severity(), Severity::Minor));
205        assert!(matches!(issue.category(), Category::Security));
206        let location = issue.location().unwrap();
207        assert_eq!(location.path, cargo_lock_toml.path());
208        assert_eq!(
209            location.message,
210            "Dependency 'if_chain' is unused as a normal dependency in package 'useless'"
211        );
212        assert_eq!(location.range.start.line, 1);
213        assert_eq!(location.range.end.line, 1);
214        assert_eq!(location.range.start.column, 0);
215        assert_eq!(location.range.end.column, 0);
216    }
217}