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#[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 Allow,
38 #[default]
41 Warn,
42 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 CratesIo(bool),
84 Git {
86 spec: GitSpec,
87 url: Url,
88 spec_value: Option<String>,
89 },
90 Registry(Url),
92 Sparse(Url),
94}
95
96fn 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 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 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 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 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 features: pkg.features,
352 publish: pkg.publish,
353 rust_version: pkg.rust_version,
354 }
355 }
356}
357
358impl Krate {
359 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(®.as_str()))
368 }
369 })
370 }
371
372 #[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 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 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
456pub struct CheckCtx<'ctx, T> {
459 pub cfg: T,
461 pub krates: &'ctx Krates,
463 pub krate_spans: &'ctx diag::KrateSpans<'ctx>,
465 pub colorize: bool,
467 pub log_level: log::LevelFilter,
470 pub files: &'ctx diag::Files,
472}
473
474#[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#[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 nq.pop();
544 url.set_query(Some(&nq));
545 }
546 }
547
548 (spec, spec_value)
549}
550
551#[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
559pub 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 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 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 #[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}