Skip to main content

indicate/
geiger.rs

1//! Module to parse the output of `cargo-geiger`
2//!
3//! This module relies on the using the flags `--output-format Json --quiet`.
4//! The output can still be quite noicy, so sometimes running `2>/dev/null` is
5//! required (pipe `stderr` to the black hole at the center of the Linuxverse).
6//!
7//! The output of `cargo-geiger` is on the form (some fields omitted)
8//! ```json
9//! {
10//!     "packages": [
11//!         {
12//!             "package": {
13//!                 "id": {
14//!                     "name": "libc",
15//!                     "version": "0.2.139",
16//!                     // Etc.
17//!                 },
18//!                 "unsafety": {
19//!                     "used": {
20//!                         "functions": {"safe":0,"unsafe_":0},
21//!                         "exprs": {"safe":253,"unsafe_":12},
22//!                         "item_impls": {"safe":11,"unsafe_":0},
23//!                         "item_traits":{"safe":0,"unsafe_":0},
24//!                         "methods":{"safe":5,"unsafe_":2}
25//!                     },
26//!                     "unused": {
27//!                         "functions": {"safe":6,"unsafe_":24},
28//!                         "exprs": {"safe":3934,"unsafe_":432},
29//!                         "item_impls": {"safe":103,"unsafe_":2},
30//!                         "item_traits": {"safe":0,"unsafe_":0},
31//!                         "methods":{"safe":46,"unsafe_":43}},
32//!                     },
33//!                     "forbids_unsafe":false
34//!                 }
35//!             }
36//!         }
37//!     ]
38//! }
39//! ```
40//!
41//! The target of this module is to make it easy to extract the data in the schema;
42//! In general this is achieved by a `total` method that allows for aggregating
43//! for example used+unused, and at a lower level safe+unsafe_.
44
45use std::{
46    collections::HashMap,
47    ops::Add,
48    process::{Command, Stdio},
49};
50
51use cargo_metadata::CargoOpt;
52use serde::Deserialize;
53
54use crate::{errors::GeigerError, ManifestPath, NameVersion};
55
56/// A client used to evaluate `cargo-geiger` information for some package
57/// and its dependencies
58#[derive(Debug)]
59pub struct GeigerClient {
60    #[cfg(test)]
61    output: GeigerOutput,
62    unsafety: HashMap<NameVersion, GeigerUnsafety>,
63}
64
65impl GeigerClient {
66    /// Creates a new client from the path one would pass to `cargo-geiger`
67    ///
68    /// Requires that `cargo-geiger` is installed on the system. The caller must
69    /// also check that `features` is a valid combination, otherwise `cargo-
70    /// geiger` may fail. An empty vector will be handled as default features.
71    ///
72    /// Will create an absolute path of `manifest_path`.
73    ///
74    /// This can be very slow, especially if the package has not been parsed
75    /// before. Therefore, it is often better to do this lazily (i.e. wrapping
76    /// in a [`Lazy`](once_cell::sync::Lazy)).
77    ///
78    /// Will redirect both `stdout` and `stderr` internally.
79    ///
80    /// # Errors
81    ///
82    /// If `cargo-geiger` fails in a way that is not due to it not being
83    /// installed, an error variant will be returned. Possible faults may be
84    /// compilation errors, missing libraries for compilation, erroneous
85    /// feature combinations etc.
86    ///
87    /// # Panics
88    ///
89    /// Panics if `cargo-geiger` is not installed and available in `$PATH`
90    pub fn new(
91        manifest_path: &ManifestPath,
92        features: Vec<CargoOpt>,
93    ) -> Result<Self, Box<GeigerError>> {
94        let mut cmd = Command::new("cargo-geiger");
95        cmd.args(["--output-format", "Json"])
96            .arg("--quiet") // Only output tree
97            .arg("--manifest-path")
98            .arg(manifest_path.as_path());
99
100        for f in features {
101            // Validity of these should be checked by CLI, not library
102            match f {
103                CargoOpt::AllFeatures => {
104                    cmd.arg("--all-features");
105                }
106                CargoOpt::NoDefaultFeatures => {
107                    cmd.arg("--no-default-features");
108                }
109                CargoOpt::SomeFeatures(s) => {
110                    if !s.is_empty() {
111                        cmd.arg("--features");
112                        cmd.args(s);
113                    }
114                }
115            }
116        }
117
118        let output = cmd
119            .stdin(Stdio::null())
120            .output()
121            .unwrap_or_else(|e| {
122                panic!(
123                    "geiger command failed to start with error: {e}, are you sure `cargo-geiger` is installed?"
124                )
125            });
126
127        if !output.status.success() {
128            // Geiger gives error codes even if its only errors codes...
129            // We let this explode somewhere else
130            println!("cargo-geiger exited with non-zero exit code, but it was ignored");
131            eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
132            // return Err(Box::new(GeigerError::NonZeroStatus(
133            //     output.status.code().unwrap_or(-1),
134            //     stderr.to_string(),
135            // )));
136        }
137
138        let stdout = String::from_utf8_lossy(&output.stdout);
139        let res = Self::from_json(&stdout);
140        match res {
141            Ok(s) => Ok(s),
142            Err(e) => Err(Box::new(GeigerError::UnexpectedOutput(
143                e.to_string(),
144                stdout.to_string(),
145            ))),
146        }
147    }
148
149    /// Parse [`GeigerOutput`] from a JSON string (i.e. the output of
150    /// `cargo-geiger` when run with `--output-format Json`)
151    ///
152    /// # Errors
153    ///
154    /// If the `cargo-geiger` output cannot be deserialized, an error variant
155    /// will be returned containing the information from `serde`.
156    pub fn from_json(geiger_output: &str) -> Result<Self, serde_json::Error> {
157        let output = serde_json::from_str::<GeigerOutput>(geiger_output)?;
158        Ok(Self::from(output))
159    }
160
161    #[must_use]
162    pub fn unsafety(&self, gid: &NameVersion) -> Option<GeigerUnsafety> {
163        self.unsafety.get(gid).copied()
164    }
165}
166
167impl From<GeigerOutput> for GeigerClient {
168    fn from(value: GeigerOutput) -> Self {
169        let mut unsafety = HashMap::with_capacity(value.packages.len());
170        for p in &value.packages {
171            unsafety.insert(p.package.id.clone(), p.unsafety);
172        }
173        Self {
174            #[cfg(test)]
175            output: value,
176            unsafety,
177        }
178    }
179}
180
181/// Calculates a percentage, rounded to two
182/// decimal points
183///
184/// This function will handle `0 / 0` to be equal to `0.0` (all code is safe,
185/// there is no code).
186#[must_use]
187pub(crate) fn two_digit_percentage(part: u32, total: u32) -> f64 {
188    let res = f64::from(part) / f64::from(total);
189    if res.is_finite() {
190        // We only really care about at most two decimal points
191        (res * 10000.0).round() / 100.0
192    } else {
193        0.0
194    }
195}
196
197/// The full output of `cargo-geiger`
198#[derive(Debug, Clone, Deserialize, Default)]
199pub struct GeigerOutput {
200    pub packages: Vec<GeigerPackageOutput>,
201}
202
203#[derive(Debug, Clone, Deserialize)]
204pub struct GeigerPackageOutput {
205    pub package: GeigerPackage,
206    pub unsafety: GeigerUnsafety,
207}
208
209/// A package in `cargo-geiger` used to identify what has been parsed
210#[derive(Debug, Clone, Deserialize)]
211pub struct GeigerPackage {
212    pub id: NameVersion,
213    // Other fields ignored
214}
215
216/// The output of `cargo-geiger` for one package/dependency
217///
218/// Corresponds to the object named "unsafety" in a `cargo-geiger` output.
219/// `used` and `unused` refers to if the code is used by the package used
220/// to provide the Geiger data. A package may have a high unsafe usage, but
221/// nothing is used by the analyzed package.
222#[derive(Debug, Clone, Copy, Deserialize)]
223pub struct GeigerUnsafety {
224    pub used: GeigerCategories,
225    pub unused: GeigerCategories,
226    pub forbids_unsafe: bool,
227}
228
229impl GeigerUnsafety {
230    /// Retrieves the total geiger count for all targets, i.e. total for used
231    /// and unused code
232    #[must_use]
233    pub fn total(&self) -> GeigerCategories {
234        GeigerCategories {
235            functions: self.used.functions + self.unused.functions,
236            exprs: self.used.exprs + self.unused.exprs,
237            item_impls: self.used.item_impls + self.unused.item_impls,
238            item_traits: self.used.item_traits + self.unused.item_traits,
239            methods: self.used.methods + self.unused.methods,
240        }
241    }
242
243    #[must_use]
244    pub fn used_safe(&self) -> u32 {
245        self.used.total_safe()
246    }
247
248    #[must_use]
249    pub fn used_unsafe(&self) -> u32 {
250        self.used.total_unsafe()
251    }
252
253    #[must_use]
254    pub fn unused_safe(&self) -> u32 {
255        self.unused.total_safe()
256    }
257
258    #[must_use]
259    pub fn unused_unsafe(&self) -> u32 {
260        self.unused.total_unsafe()
261    }
262
263    #[must_use]
264    pub fn total_safe(&self) -> u32 {
265        self.used_safe() + self.unused_safe()
266    }
267
268    #[must_use]
269    pub fn total_unsafe(&self) -> u32 {
270        self.used_unsafe() + self.unused_unsafe()
271    }
272
273    /// Calculates the percentage of the package to be unsafe, to two decimal
274    /// points
275    ///
276    /// Uses the total unsafe and total safe code as basis.
277    #[must_use]
278    pub fn percentage_unsafe(&self) -> f64 {
279        two_digit_percentage(
280            self.total_unsafe(),
281            self.total_safe() + self.total_unsafe(),
282        )
283    }
284}
285
286/// All different targets in Rust code that `cargo-geiger` counts
287#[derive(Debug, Clone, Copy, Deserialize)]
288pub struct GeigerCategories {
289    pub functions: GeigerCount,
290    pub exprs: GeigerCount,
291    pub item_impls: GeigerCount,
292    pub item_traits: GeigerCount,
293    pub methods: GeigerCount,
294}
295
296impl GeigerCategories {
297    /// Aggregates all [`GeigerCount`] for all categories, returning one with
298    /// total safe and total unsafe for all categories
299    #[must_use]
300    pub fn total(&self) -> GeigerCount {
301        self.functions
302            + self.exprs
303            + self.item_impls
304            + self.item_traits
305            + self.methods
306    }
307
308    #[must_use]
309    pub fn total_safe(&self) -> u32 {
310        self.functions.safe
311            + self.exprs.safe
312            + self.item_impls.safe
313            + self.item_traits.safe
314            + self.methods.safe
315    }
316
317    #[must_use]
318    pub fn total_unsafe(&self) -> u32 {
319        self.functions.unsafe_
320            + self.exprs.unsafe_
321            + self.item_impls.unsafe_
322            + self.item_traits.unsafe_
323            + self.methods.unsafe_
324    }
325}
326
327impl Add<GeigerCategories> for GeigerCategories {
328    type Output = GeigerCategories;
329
330    fn add(self, rhs: GeigerCategories) -> Self::Output {
331        GeigerCategories {
332            functions: self.functions + rhs.functions,
333            exprs: self.exprs + rhs.exprs,
334            item_impls: self.item_impls + rhs.item_impls,
335            item_traits: self.item_traits + rhs.item_traits,
336            methods: self.methods + rhs.methods,
337        }
338    }
339}
340
341/// The safety stats for a package analyzed by `cargo-geiger`,
342/// i.e. counts for lines of safe and unsafe code
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
344pub struct GeigerCount {
345    pub safe: u32,
346    pub unsafe_: u32,
347}
348
349impl GeigerCount {
350    /// The total amount of counts made by Geiger
351    #[must_use]
352    pub fn total(&self) -> u32 {
353        self.safe + self.unsafe_
354    }
355
356    /// Calculate the percentage of the count that is unsafe to two digits
357    /// precisions
358    #[must_use]
359    pub fn percentage_unsafe(&self) -> f64 {
360        two_digit_percentage(self.unsafe_, self.total())
361    }
362}
363
364impl Add<GeigerCount> for GeigerCount {
365    type Output = GeigerCount;
366
367    fn add(self, rhs: GeigerCount) -> Self::Output {
368        GeigerCount {
369            safe: self.safe + rhs.safe,
370            unsafe_: self.unsafe_ + rhs.unsafe_,
371        }
372    }
373}
374
375#[cfg(test)]
376mod test {
377    use std::{fs, path::Path};
378
379    use test_case::test_case;
380
381    use crate::{geiger::GeigerCount, ManifestPath};
382
383    use super::{GeigerClient, GeigerOutput};
384
385    #[test_case(0, 0 => 0.0)]
386    #[test_case(3, 1 => 25.0)]
387    #[test_case(9, 1 => 10.0)]
388    #[test_case(2, 1 => 33.33)]
389    fn two_digit_percentage(safe_count: u32, unsafe_count: u32) -> f64 {
390        super::two_digit_percentage(unsafe_count, safe_count + unsafe_count)
391    }
392
393    #[test_case("simple_deps")]
394    #[test_case("known_advisory_deps")]
395    #[test_case("feature_deps")]
396    #[test_case("forbids_unsafe")]
397    fn geiger_from_path(crate_name: &'static str) {
398        let path_string =
399            format!("test_data/fake_crates/{crate_name}/Cargo.toml");
400        let path = ManifestPath::from(path_string);
401        GeigerClient::new(&path, vec![]).unwrap();
402    }
403
404    #[test_case("simple_deps")]
405    fn deserialize_geiger_output_smoke_test(crate_name: &'static str) {
406        let path_string = format!("test_data/geiger-output/{crate_name}.json");
407        let path = Path::new(&path_string);
408        let json_string = fs::read_to_string(path).unwrap();
409        serde_json::from_str::<GeigerOutput>(&json_string).unwrap();
410    }
411
412    #[test_case(0, 0, 0, 0)]
413    #[test_case(1, 1, 0, 0)]
414    #[test_case(1, 2, 3, 4)]
415    fn add_geiger_counts(safe0: u32, unsafe0: u32, safe1: u32, unsafe1: u32) {
416        let gc0 = GeigerCount {
417            safe: safe0,
418            unsafe_: unsafe0,
419        };
420        let gc1 = GeigerCount {
421            safe: safe1,
422            unsafe_: unsafe1,
423        };
424        let gc_res = GeigerCount {
425            safe: safe0 + safe1,
426            unsafe_: unsafe0 + unsafe1,
427        };
428        assert_eq!(gc0 + gc1, gc_res);
429    }
430}