Skip to main content

cargo_deny/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub use semver::Version;
4use std::{cmp, collections::BTreeMap, fmt};
5use url::Url;
6
7pub mod advisories;
8pub mod bans;
9pub mod cfg;
10pub mod diag;
11pub mod git;
12pub mod licenses;
13pub mod root_cfg;
14pub mod sarif;
15pub mod sources;
16
17#[doc(hidden)]
18pub mod test_utils;
19
20pub use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
21pub use cfg::UnvalidatedConfig;
22use krates::cm;
23pub use krates::{DepKind, Kid};
24pub use toml_span::{
25    Deserialize, Error,
26    span::{Span, Spanned},
27};
28
29/// The possible lint levels for the various lints. These function similarly
30/// to the standard [Rust lint levels](https://doc.rust-lang.org/rustc/lints/levels.html)
31#[derive(PartialEq, Eq, Clone, Copy, Debug, Default, strum::VariantNames, strum::VariantArray)]
32#[cfg_attr(test, derive(serde::Serialize))]
33#[cfg_attr(test, serde(rename_all = "kebab-case"))]
34#[strum(serialize_all = "kebab-case")]
35pub enum LintLevel {
36    /// A debug or info diagnostic _may_ be emitted if the lint is violated
37    Allow,
38    /// A warning will be emitted if the lint is violated, but the command
39    /// will succeed
40    #[default]
41    Warn,
42    /// An error will be emitted if the lint is violated, and the command
43    /// will fail with a non-zero exit code
44    Deny,
45}
46
47#[macro_export]
48macro_rules! enum_deser {
49    ($enum:ty) => {
50        impl<'de> toml_span::Deserialize<'de> for $enum {
51            fn deserialize(
52                value: &mut toml_span::value::Value<'de>,
53            ) -> Result<Self, toml_span::DeserError> {
54                let s = value.take_string(Some(stringify!($enum)))?;
55
56                use strum::{VariantArray, VariantNames};
57
58                let Some(pos) = <$enum as VariantNames>::VARIANTS
59                    .iter()
60                    .position(|v| *v == s.as_ref())
61                else {
62                    return Err(toml_span::Error::from((
63                        toml_span::ErrorKind::UnexpectedValue {
64                            expected: <$enum as VariantNames>::VARIANTS,
65                            value: None,
66                        },
67                        value.span,
68                    ))
69                    .into());
70                };
71
72                Ok(<$enum as VariantArray>::VARIANTS[pos])
73            }
74        }
75    };
76}
77
78enum_deser!(LintLevel);
79
80#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
81pub enum Source {
82    /// crates.io, the boolean indicates whether it is a sparse index
83    CratesIo(bool),
84    /// A remote git patch
85    Git {
86        spec: GitSpec,
87        url: Url,
88        spec_value: Option<String>,
89    },
90    /// A remote non-sparse registry index
91    Registry(Url),
92    /// A remote sparse index
93    Sparse(Url),
94}
95
96/// The directory name under which crates sourced from the crates.io sparse
97/// registry are placed
98fn crates_io_sparse_dir() -> &'static str {
99    static mut CRATES_IO_SPARSE_DIR: String = String::new();
100    static CRATES_IO_INIT: parking_lot::Once = parking_lot::Once::new();
101
102    #[allow(unsafe_code)]
103    // SAFETY: We're mutating a static, but we only allow one mutation
104    unsafe {
105        CRATES_IO_INIT.call_once(|| {
106            let Ok(version) = tame_index::utils::cargo_version(None) else {
107                return;
108            };
109            let Ok(url_dir) = tame_index::utils::url_to_local_dir(
110                tame_index::CRATES_IO_HTTP_INDEX,
111                version >= semver::Version::new(1, 85, 0),
112            ) else {
113                return;
114            };
115            CRATES_IO_SPARSE_DIR = url_dir.dir_name;
116        });
117
118        #[allow(static_mut_refs)]
119        &CRATES_IO_SPARSE_DIR
120    }
121}
122
123impl Source {
124    pub fn crates_io(is_sparse: bool) -> Self {
125        Self::CratesIo(is_sparse)
126    }
127
128    /// Parses the source url to get its kind
129    ///
130    /// Note that the path is the path to the manifest of the package. This is
131    /// used to determine if the crates.io registry is git or sparse, as, currently,
132    /// cargo always uses the git registry+ url for crates.io, even if it uses the
133    /// sparse registry.
134    ///
135    /// This method therefore assumes that the crates sources are laid out in the
136    /// canonical cargo structure, though it can be rooted somewhere other than
137    /// `CARGO_HOME`
138    fn from_metadata(urls: String, manifest_path: Option<&Path>) -> anyhow::Result<Self> {
139        use anyhow::Context as _;
140
141        let (kind, url_str) = urls
142            .split_once('+')
143            .with_context(|| format!("'{urls}' is not a valid crate source"))?;
144
145        match kind {
146            "sparse" => {
147                // This code won't ever be hit in current cargo, but could in the future
148                if urls == tame_index::CRATES_IO_HTTP_INDEX {
149                    Ok(Self::crates_io(true))
150                } else {
151                    Url::parse(&urls)
152                        .map(Self::Sparse)
153                        .context("failed to parse url")
154                }
155            }
156            "registry" => {
157                if url_str == tame_index::CRATES_IO_INDEX {
158                    // registry/src/index.crates.io-6f17d22bba15001f/crate-version/Cargo.toml
159                    let is_sparse = manifest_path.is_none_or(|mp| {
160                        mp.ancestors().nth(2).is_some_and(|dir| {
161                            dir.file_name()
162                                .is_some_and(|dir_name| dir_name == crates_io_sparse_dir())
163                        })
164                    });
165                    Ok(Self::crates_io(is_sparse))
166                } else {
167                    Url::parse(url_str)
168                        .map(Self::Registry)
169                        .context("failed to parse url")
170                }
171            }
172            "git" => {
173                let mut url = Url::parse(url_str).context("failed to parse url")?;
174                let (spec, spec_value) = normalize_git_url(&mut url);
175
176                Ok(Self::Git {
177                    url,
178                    spec,
179                    spec_value,
180                })
181            }
182            unknown => anyhow::bail!("unknown source spec '{unknown}' for url {urls}"),
183        }
184    }
185
186    #[inline]
187    pub fn is_git(&self) -> bool {
188        matches!(self, Self::Git { .. })
189    }
190
191    #[inline]
192    pub fn git_spec(&self) -> Option<GitSpec> {
193        let Self::Git { spec, .. } = self else {
194            return None;
195        };
196        Some(*spec)
197    }
198
199    #[inline]
200    pub fn is_registry(&self) -> bool {
201        !self.is_git()
202    }
203
204    #[inline]
205    pub fn is_crates_io(&self) -> bool {
206        matches!(self, Self::CratesIo(_))
207    }
208
209    #[inline]
210    pub fn matches_rustsec(&self, sid: Option<&Self>) -> bool {
211        let Some(sid) = sid else {
212            return self.is_crates_io();
213        };
214
215        match (self, sid) {
216            (Self::Registry(a), Self::Registry(b)) | (Self::Sparse(a), Self::Sparse(b)) => a == b,
217            _ => false,
218        }
219    }
220}
221
222impl fmt::Display for Source {
223    #[inline]
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        match self {
226            Self::CratesIo(_) => {
227                write!(f, "registry+{}", tame_index::CRATES_IO_INDEX)
228            }
229            Self::Git { url, .. } => {
230                write!(f, "git+{url}")
231            }
232            Self::Registry(url) => {
233                write!(f, "registry+{url}")
234            }
235            Self::Sparse(url) => {
236                write!(f, "{url}")
237            }
238        }
239    }
240}
241
242#[derive(Debug)]
243pub struct Krate {
244    pub name: String,
245    pub id: Kid,
246    pub version: Version,
247    pub source: Option<Source>,
248    pub authors: Vec<String>,
249    pub repository: Option<String>,
250    pub description: Option<String>,
251    pub manifest_path: PathBuf,
252    pub license: Option<String>,
253    pub license_file: Option<PathBuf>,
254    pub deps: Vec<cm::Dependency>,
255    pub features: BTreeMap<String, Vec<String>>,
256    pub targets: Vec<cm::Target>,
257    pub publish: Option<Vec<String>>,
258    pub rust_version: Option<Version>,
259}
260
261#[cfg(test)]
262impl Default for Krate {
263    fn default() -> Self {
264        Self {
265            name: "".to_owned(),
266            version: Version::new(0, 1, 0),
267            authors: Vec::new(),
268            id: Kid::default(),
269            source: None,
270            description: None,
271            deps: Vec::new(),
272            license: None,
273            license_file: None,
274            targets: Vec::new(),
275            features: BTreeMap::new(),
276            manifest_path: PathBuf::new(),
277            repository: None,
278            publish: None,
279            rust_version: None,
280        }
281    }
282}
283
284impl PartialOrd for Krate {
285    fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
286        Some(self.cmp(other))
287    }
288}
289
290impl Ord for Krate {
291    fn cmp(&self, other: &Self) -> cmp::Ordering {
292        self.id.cmp(&other.id)
293    }
294}
295
296impl PartialEq for Krate {
297    fn eq(&self, other: &Self) -> bool {
298        self.id == other.id
299    }
300}
301
302impl Eq for Krate {}
303
304impl krates::KrateDetails for Krate {
305    #[inline]
306    fn name(&self) -> &str {
307        &self.name
308    }
309
310    #[inline]
311    fn version(&self) -> &semver::Version {
312        &self.version
313    }
314}
315
316impl From<cm::Package> for Krate {
317    fn from(pkg: cm::Package) -> Self {
318        let source = pkg.source.and_then(|src| {
319            let url = src.to_string();
320
321            Source::from_metadata(url, Some(&pkg.manifest_path))
322                .map_err(|err| {
323                    log::warn!(
324                        "unable to parse source url for {}:{}: {err}",
325                        pkg.name,
326                        pkg.version
327                    );
328                    err
329                })
330                .ok()
331        });
332
333        Self {
334            name: pkg.name,
335            id: pkg.id.into(),
336            version: pkg.version,
337            authors: pkg.authors,
338            repository: pkg.repository,
339            source,
340            targets: pkg.targets,
341            license: pkg.license,
342            license_file: pkg.license_file,
343            description: pkg.description,
344            manifest_path: pkg.manifest_path,
345            deps: pkg.dependencies,
346            // {
347            //     let mut deps = pkg.dependencies;
348            //     deps.sort_by(|a, b| a.name.cmp(&b.name));
349            //     deps
350            // },
351            features: pkg.features,
352            publish: pkg.publish,
353            rust_version: pkg.rust_version,
354        }
355    }
356}
357
358impl Krate {
359    /// Returns true if the crate is marked as `publish = false`, or
360    /// it is only published to the specified private registries
361    pub(crate) fn is_private(&self, private_registries: &[&str]) -> bool {
362        self.publish.as_ref().is_some_and(|v| {
363            if v.is_empty() {
364                true
365            } else {
366                v.iter()
367                    .all(|reg| private_registries.contains(&reg.as_str()))
368            }
369        })
370    }
371
372    /// Determines if the specified url matches the source
373    #[inline]
374    pub(crate) fn matches_url(&self, url: &Url, exact: bool) -> bool {
375        let Some(src) = &self.source else {
376            return false;
377        };
378
379        let kurl = match src {
380            Source::CratesIo(_is_sparse) => {
381                // It's irrelevant if it's sparse or not for crates.io, they're the same
382                // index, just different protocols/kinds
383                return url
384                    .as_str()
385                    .ends_with(&tame_index::CRATES_IO_HTTP_INDEX[8..])
386                    || url.as_str().ends_with(&tame_index::CRATES_IO_INDEX[10..]);
387            }
388            Source::Sparse(surl) | Source::Registry(surl) | Source::Git { url: surl, .. } => surl,
389        };
390
391        kurl.host() == url.host()
392            && ((exact && kurl.path() == url.path())
393                || (!exact && kurl.path().starts_with(url.path())))
394    }
395
396    #[inline]
397    pub(crate) fn is_crates_io(&self) -> bool {
398        self.source.as_ref().is_some_and(|src| src.is_crates_io())
399    }
400
401    #[inline]
402    pub(crate) fn is_git_source(&self) -> bool {
403        self.source.as_ref().is_some_and(|src| src.is_git())
404    }
405
406    #[inline]
407    pub(crate) fn is_registry(&self) -> bool {
408        self.source.as_ref().is_some_and(|src| src.is_registry())
409    }
410}
411
412impl fmt::Display for Krate {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        write!(f, "{} = {}", self.name, self.version)
415    }
416}
417
418pub type Krates = krates::Krates<Krate>;
419
420#[inline]
421pub fn binary_search<T, Q>(s: &[T], query: &Q) -> Result<usize, usize>
422where
423    T: std::borrow::Borrow<Q>,
424    Q: Ord + ?Sized,
425{
426    s.binary_search_by(|i| i.borrow().cmp(query))
427}
428
429#[inline]
430pub fn contains<T, Q>(s: &[T], query: &Q) -> bool
431where
432    T: std::borrow::Borrow<Q>,
433    Q: Eq + ?Sized,
434{
435    s.iter().any(|i| i.borrow() == query)
436}
437
438#[inline]
439pub fn hash(data: &[u8]) -> u32 {
440    use std::hash::Hasher;
441    // We use the 32-bit hash instead of the 64 even though
442    // it is significantly slower due to the TOML limitation
443    // if only supporting i64
444    let mut xx = twox_hash::XxHash32::default();
445    xx.write(data);
446    xx.finish() as u32
447}
448
449#[derive(Clone, Copy)]
450pub enum SerializeAdvisory {
451    Json,
452    Sarif,
453    No,
454}
455
456/// Common context for the various checks. Some checks require additional
457/// information though.
458pub struct CheckCtx<'ctx, T> {
459    /// The configuration for the check
460    pub cfg: T,
461    /// The krates graph to check
462    pub krates: &'ctx Krates,
463    /// The spans for each unique crate in a synthesized "lock file"
464    pub krate_spans: &'ctx diag::KrateSpans<'ctx>,
465    /// Allows for ANSI colorization of diagnostic content
466    pub colorize: bool,
467    /// Log level specified by the user, may be used by checks to determine what
468    /// information to emit in diagnostics
469    pub log_level: log::LevelFilter,
470    /// Files that can show span information in diagnostics
471    pub files: &'ctx diag::Files,
472}
473
474/// Checks if a version satisfies the specifies the specified version requirement.
475/// If the requirement is `None` then it is also satisfied.
476#[inline]
477pub fn match_req(version: &Version, req: Option<&semver::VersionReq>) -> bool {
478    req.is_none_or(|req| req.matches(version))
479}
480
481#[inline]
482pub fn match_krate(krate: &Krate, pid: &cfg::PackageSpec) -> bool {
483    krate.name == pid.name.value && match_req(&krate.version, pid.version_req.as_ref())
484}
485
486use sources::cfg::GitSpec;
487
488/// Normalizes the URL so that different representations can be compared to each other.
489///
490/// At the moment we just remove a tailing `.git` but there are more possible optimisations.
491///
492/// See <https://github.com/rust-lang/cargo/blob/1f6c6bd5e7bbdf596f7e88e6db347af5268ab113/src/cargo/util/canonical_url.rs#L31-L57>
493/// for what cargo does
494#[inline]
495pub(crate) fn normalize_git_url(url: &mut Url) -> (GitSpec, Option<String>) {
496    const GIT_EXT: &str = ".git";
497
498    let needs_chopping = url.path().ends_with(&GIT_EXT);
499    if needs_chopping {
500        let last = {
501            let last = url.path_segments().unwrap().next_back().unwrap();
502            last[..last.len() - GIT_EXT.len()].to_owned()
503        };
504        url.path_segments_mut().unwrap().pop().push(&last);
505    }
506
507    if url.path().ends_with('/') {
508        url.path_segments_mut().unwrap().pop_if_empty();
509    }
510
511    let mut spec = GitSpec::Any;
512    let mut spec_value = None;
513
514    for (k, v) in url.query_pairs() {
515        spec = match k.as_ref() {
516            "branch" | "ref" => GitSpec::Branch,
517            "tag" => GitSpec::Tag,
518            "rev" => GitSpec::Rev,
519            _ => continue,
520        };
521
522        spec_value = Some(v.into_owned());
523    }
524
525    if url
526        .query_pairs()
527        .any(|(k, v)| k == "branch" && v == "master")
528    {
529        if url.query_pairs().count() == 1 {
530            url.set_query(None);
531        } else {
532            let mut nq = String::new();
533            for (k, v) in url.query_pairs() {
534                if k == "branch" && v == "master" {
535                    continue;
536                }
537
538                use std::fmt::Write;
539                write!(&mut nq, "{k}={v}&").unwrap();
540            }
541
542            // pop trailing &
543            nq.pop();
544            url.set_query(Some(&nq));
545        }
546    }
547
548    (spec, spec_value)
549}
550
551/// Helper function to convert a std `PathBuf` to a camino one
552#[inline]
553#[allow(clippy::disallowed_types)]
554pub fn utf8path(pb: std::path::PathBuf) -> anyhow::Result<PathBuf> {
555    use anyhow::Context;
556    PathBuf::try_from(pb).context("non-utf8 path")
557}
558
559/// Adds the crates.io index with the specified settings to the builder for
560/// feature resolution
561pub fn krates_with_index(
562    kb: &mut krates::Builder,
563    config_root: Option<PathBuf>,
564    cargo_home: Option<PathBuf>,
565) -> anyhow::Result<()> {
566    use anyhow::Context as _;
567    let crates_io = tame_index::IndexUrl::crates_io(config_root, cargo_home.as_deref(), None)
568        .context("unable to determine crates.io url")?;
569
570    let index = tame_index::index::ComboIndexCache::new(
571        tame_index::IndexLocation::new(crates_io).with_root(cargo_home.clone()),
572    )
573    .context("unable to open local crates.io index")?;
574
575    // Note we don't take a lock here ourselves, since we are calling cargo
576    // it will take the lock and only give us results if it gets access, if we
577    // took a look we would deadlock here
578    let lock = tame_index::utils::flock::FileLock::unlocked();
579
580    let index_cache_build = move |krates: std::collections::BTreeSet<String>| {
581        let mut cache = std::collections::BTreeMap::new();
582        for name in krates {
583            let read = || -> Option<krates::index::IndexKrate> {
584                let name = name.as_str().try_into().ok()?;
585                let krate = index.cached_krate(name, &lock).ok()??;
586                let versions = krate
587                    .versions
588                    .into_iter()
589                    .filter_map(|kv| {
590                        // The index (currently) can have both features, and
591                        // features2, the features method gives us an iterator
592                        // over both
593                        kv.version.parse::<semver::Version>().ok().map(|version| {
594                            krates::index::IndexKrateVersion {
595                                version,
596                                features: kv
597                                    .features()
598                                    .map(|(k, v)| (k.clone(), v.clone()))
599                                    .collect(),
600                            }
601                        })
602                    })
603                    .collect();
604
605                Some(krates::index::IndexKrate { versions })
606            };
607
608            let krate = read();
609            cache.insert(name, krate);
610        }
611
612        cache
613    };
614
615    kb.with_crates_io_index(Box::new(index_cache_build));
616
617    Ok(())
618}
619
620use anyhow::Context as _;
621
622#[inline]
623#[allow(clippy::disallowed_types)]
624fn not_utf8(p: std::path::PathBuf, id: &str) -> anyhow::Error {
625    anyhow::anyhow!("{id}({p:?}) is not a utf-8 path")
626}
627
628#[inline]
629pub fn home() -> anyhow::Result<PathBuf> {
630    let home = std::env::home_dir().context("$HOME is not available")?;
631    PathBuf::from_path_buf(home).map_err(|p| not_utf8(p, "$HOME"))
632}
633
634pub fn cargo_home() -> anyhow::Result<PathBuf> {
635    let Some(ch) = std::env::var_os("CARGO_HOME").filter(|ch| !ch.is_empty()) else {
636        let mut ch = home()?;
637        ch.push(".cargo");
638        return Ok(ch);
639    };
640
641    let home = PathBuf::from_os_string(ch).map_err(|p| not_utf8(p.into(), "$CARGO_HOME"))?;
642    if home.is_absolute() {
643        return Ok(home);
644    }
645
646    let cwd = std::env::current_dir().context("failed to retrieve current working directory")?;
647    let mut cwd =
648        PathBuf::from_path_buf(cwd).map_err(|p| not_utf8(p, "current working directory"))?;
649    cwd.push(home);
650    Ok(cwd)
651}
652
653#[cfg(test)]
654mod test {
655    use super::{Krate, Path, Source, Url};
656
657    #[test]
658    fn parses_sources() {
659        let empty_dir = Some(Path::new(""));
660        let crates_io_git = Source::from_metadata(
661            format!("registry+{}", tame_index::CRATES_IO_INDEX),
662            empty_dir,
663        )
664        .unwrap();
665        let crates_io_sparse =
666            Source::from_metadata(tame_index::CRATES_IO_HTTP_INDEX.to_owned(), empty_dir).unwrap();
667        let crates_io_sparse_but_git = Source::from_metadata(
668            format!("registry+{}", tame_index::CRATES_IO_INDEX),
669            Some(Path::new(&format!(
670                "registry/src/{}/cargo-deny-0.69.0/Cargo.toml",
671                super::crates_io_sparse_dir(),
672            ))),
673        )
674        .unwrap();
675
676        assert!(
677            crates_io_git.is_registry()
678                && crates_io_sparse.is_registry()
679                && crates_io_sparse_but_git.is_registry()
680        );
681        assert!(
682            crates_io_git.is_crates_io()
683                && crates_io_sparse.is_crates_io()
684                && crates_io_sparse_but_git.is_crates_io()
685        );
686
687        assert!(
688            Source::from_metadata("registry+https://my-own-my-precious.com/".to_owned(), None)
689                .unwrap()
690                .is_registry()
691        );
692        assert!(
693            Source::from_metadata("sparse+https://my-registry.rs/".to_owned(), None)
694                .unwrap()
695                .is_registry()
696        );
697
698        let src = Source::from_metadata("git+https://github.com/EmbarkStudios/wasmtime?branch=v6.0.1-profiler#84b8cacceacb585ef53774c3790b2372ba080067".to_owned(), empty_dir).unwrap();
699
700        assert!(src.is_git());
701    }
702
703    /// Sanity checks that the crates.io sparse registry still uses the same
704    /// local directory. Really this should be doing a cargo invocation, but
705    /// meh, we depend on tame-index to stay up to date
706    #[test]
707    fn validate_crates_io_sparse_dir_name() {
708        let stable =
709            tame_index::utils::cargo_version(None).unwrap() >= tame_index::Version::new(1, 85, 0);
710        assert_eq!(
711            tame_index::utils::url_to_local_dir(tame_index::CRATES_IO_HTTP_INDEX, stable)
712                .unwrap()
713                .dir_name,
714            super::crates_io_sparse_dir(),
715        );
716    }
717
718    #[test]
719    fn inexact_match_fails_for_different_hosts() {
720        let krate = Krate {
721            source: Some(
722                Source::from_metadata(
723                    "git+ssh://git@repo1.test.org/path/test.git".to_owned(),
724                    None,
725                )
726                .unwrap(),
727            ),
728            ..Krate::default()
729        };
730        let url = Url::parse("ssh://git@repo2.test.org:8000").unwrap();
731
732        assert!(!krate.matches_url(&url, false));
733    }
734
735    #[test]
736    fn inexact_match_passes_for_same_hosts() {
737        let krate = Krate {
738            source: Some(
739                Source::from_metadata(
740                    "git+ssh://git@repo1.test.org/path/test.git".to_owned(),
741                    None,
742                )
743                .unwrap(),
744            ),
745            ..Krate::default()
746        };
747        let url = Url::parse("ssh://git@repo1.test.org:8000").unwrap();
748
749        assert!(krate.matches_url(&url, false));
750    }
751}