deps_engine/classify/license.rs
1//! Tier-3 license pre-fetch fan-out: adapter-agnostic dispatch to
2//! [`deps_core::Ecosystem::fetch_license`] (issue #660/#688/#697, spec 010 plan §1 tier 3).
3//!
4//! Today this covers pub.dev's `/score` endpoint (Dart), the GitHub repository API
5//! (Swift), a Maven Central POM fetch (Gradle), and the JSR per-version API (Deno).
6//!
7//! Extracted from `deps-lsp`'s `document/osv_scan.rs::run_license_prefetch` (issue #1133) so
8//! `deps-cli` reaches the same license-policy verdicts as `deps-lsp` for these four ecosystems,
9//! instead of silently seeing no tier-3 license data at all. The orchestration around this
10//! dispatch — spawning it concurrently with the registry fetch, joining it before a diagnostics
11//! publish, the mid-flight staleness guard, and the additive `DocumentState::licenses` merge —
12//! stays in `deps-lsp`, since it owns a document lifecycle this crate must not know about
13//! (same split rationale as issue #1059).
14//!
15//! **Not universally off the critical path** (critic S1/M3 correction of this module's
16//! original doc): `deps-lsp` only ever calls this from a background pre-fetch task, but
17//! `deps-cli check` calls [`prefetch_tier3_licenses`] directly, concurrently with the OSV
18//! scan, whenever a non-empty `license_policy` is configured — on that adapter it is on the
19//! check gate's critical path. Callers own their own `concurrency`/timeout tradeoffs; see
20//! [`fetch_tier3_licenses`]'s parameters.
21
22use deps_core::ConcreteVersion;
23use deps_core::Ecosystem;
24use deps_core::EcosystemId;
25use deps_core::PackageName;
26use deps_core::lsp_helpers::resolve_in_use_version;
27use std::collections::HashMap;
28use std::time::Duration;
29
30/// Ceiling on the per-dependency tier-3 license fetch timeout, independent of the caller's
31/// configured `fetch_timeout_secs`: the shared `reqwest` client behind `deps-lsp`'s
32/// `HttpCache` already imposes its own client-wide 30s timeout, so a per-call timeout longer
33/// than that would never actually bind.
34const TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS: u64 = 30;
35
36/// Floor on the per-dependency tier-3 license fetch timeout, independent of the caller's
37/// configured `fetch_timeout_secs` (issue #692 critic M2). `fetch_timeout_secs` is
38/// user-configurable down to a minimum of 1s, but `deps_gradle::license::fetch_license_from`
39/// may perform up to `deps_gradle::license::MAX_POM_FETCHES` **sequential** HTTPS round trips
40/// inside the single timeout this budget bounds — a low `fetch_timeout_secs` would otherwise
41/// silently starve exactly the parent-chained artifacts (e.g. Guava) issue #692 exists to
42/// resolve. A deliberate accuracy-over-latency tradeoff for every caller, `deps-cli` included
43/// (issue #1133 critic M3): a `deps-cli check --fetch-timeout-secs 1` run against an
44/// unreachable Maven Central still waits out this floor per Gradle dependency (bounded by
45/// `concurrency`, see [`fetch_tier3_licenses`]) rather than silently under-reporting Guava's
46/// parent-chained license.
47const TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS: u64 = 10;
48
49/// Result of [`prefetch_tier3_licenses`]/[`fetch_tier3_licenses`].
50///
51/// # Examples
52///
53/// ```
54/// use deps_engine::classify::license::TierThreeLicenseFetch;
55/// use std::collections::HashMap;
56///
57/// let result = TierThreeLicenseFetch::new(HashMap::new(), 0);
58/// assert!(result.licenses.is_empty());
59/// assert_eq!(result.timed_out, 0);
60/// ```
61#[non_exhaustive]
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct TierThreeLicenseFetch {
64 /// The successfully fetched licenses, keyed by (already scheme-qualified where
65 /// applicable, e.g. `jsr:@std/fs`) package name. A dependency absent from this map means
66 /// either its fetch found no license, or it was skipped by [`tier3_license_targets`] —
67 /// the two are indistinguishable here (see [`Self::timed_out`]'s doc for why only a
68 /// timeout is ever observable as a distinct failure mode).
69 pub licenses: HashMap<PackageName, Vec<String>>,
70 /// How many per-dependency fetches were cut off by this function's own
71 /// `tokio::time::timeout` (issue #1133 critic S1).
72 ///
73 /// A **partial**, best-effort "could this result be trusted" signal, not a full
74 /// fetch-failure count: [`deps_core::Ecosystem::fetch_license`] returns a bare
75 /// `Vec<String>` with no error channel, and every one of the four tier-3 implementations
76 /// (Dart/Swift/Gradle/Deno) deliberately degrades a network error, a 404, or a GitHub
77 /// rate limit to an empty `Vec` internally, before this function ever sees it — that
78 /// class of failure is indistinguishable from "genuinely no license" and is *not*
79 /// counted here. Only this function's own outer timeout firing is observable from
80 /// outside the ecosystem crate's own implementation. Callers that need a
81 /// "network-reachable" signal (e.g. `deps-cli`'s `registry_unreachable`/exit-code gate)
82 /// should treat a non-zero count as at least one confirmed unreachable tier-3 source,
83 /// while treating zero as "no *confirmed* failure" rather than "fully verified reachable".
84 pub timed_out: usize,
85}
86
87impl TierThreeLicenseFetch {
88 /// Constructs a result from its already-computed fields. `#[non_exhaustive]` blocks
89 /// cross-crate struct-literal construction even with every field named, so a caller
90 /// outside this crate (e.g. a `deps-lsp`/`deps-cli` unit test building a synthetic
91 /// result) needs this constructor instead.
92 ///
93 /// # Examples
94 ///
95 /// ```
96 /// use deps_engine::classify::license::TierThreeLicenseFetch;
97 /// use std::collections::HashMap;
98 ///
99 /// let result = TierThreeLicenseFetch::new(HashMap::new(), 2);
100 /// assert_eq!(result.timed_out, 2);
101 /// ```
102 #[must_use]
103 pub const fn new(licenses: HashMap<PackageName, Vec<String>>, timed_out: usize) -> Self {
104 Self {
105 licenses,
106 timed_out,
107 }
108 }
109}
110
111/// Builds the `(name, in-use version)` pairs [`prefetch_tier3_licenses`] should fetch a
112/// license for.
113///
114/// The pure, network-free half of that function, split out so it is unit-testable without a
115/// `dyn Ecosystem` (mirrors [`crate::classify::osv::build_scan_targets`]'s split of decision
116/// logic from the network call).
117///
118/// Filters on
119/// [`deps_core::lsp_helpers::SourcePolicy::source_is_public_registry_content`] (critic M3/S6
120/// of the original `deps-lsp` implementation), the same stricter filter
121/// [`crate::classify::osv::build_scan_targets`]'s OSV path already uses, not the looser
122/// [`deps_core::lsp_helpers::SourcePolicy::can_resolve_source`]: a patched git/path fork is
123/// resolvable but must never have its license misattributed to the upstream registry package
124/// it forked from — the identical "is this really the same package" problem OSV's stricter
125/// filter exists to solve.
126///
127/// Deduplicated by `(name, version)` pair (issue #1133 code-review finding #2, first
128/// occurrence wins): a manifest declaring the same dependency under two sections (e.g.
129/// Gradle's `implementation` and `testImplementation`) at the same in-use version must not
130/// fetch its license twice — wasted work that undermines the exact rate-limit concern
131/// (Swift's unauthenticated 60 req/h GitHub budget) this module elsewhere worries about. Two
132/// occurrences of the same name at *different* in-use versions are kept as separate
133/// targets, since Gradle's POM fetch and Deno's JSR API are genuinely version-specific (see
134/// [`prefetch_tier3_licenses`]'s doc) — deduplicating those would silently drop a
135/// legitimately different lookup, mirroring the main registry fetch's own
136/// name-not-name+version dedup being *wrong* for this per-version-fetch shape.
137///
138/// # Examples
139///
140/// ```
141/// use deps_core::lsp_helpers::{
142/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
143/// RequirementResolution, SourcePolicy,
144/// };
145/// use deps_core::test_util::stub_parse_result_with_dependencies;
146/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
147/// use deps_engine::classify::license::tier3_license_targets;
148/// use std::collections::HashMap;
149///
150/// struct SimpleFormatter;
151/// impl PackageNaming for SimpleFormatter {}
152/// impl PackageRendering for SimpleFormatter {
153/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
154/// version.to_string()
155/// }
156/// fn package_url(&self, name: &PackageName) -> String {
157/// name.as_str().to_string()
158/// }
159/// }
160/// impl RequirementResolution for SimpleFormatter {}
161/// impl DiagnosticMessages for SimpleFormatter {}
162/// impl DiagnosticPolicy for SimpleFormatter {}
163/// impl SourcePolicy for SimpleFormatter {}
164/// impl OsvNaming for SimpleFormatter {}
165///
166/// let parsed = stub_parse_result_with_dependencies(1);
167/// let mut resolved_versions = HashMap::new();
168/// resolved_versions.insert(PackageName::new("dep-0"), ConcreteVersion::from("1.0.0"));
169///
170/// let targets = tier3_license_targets(
171/// parsed.as_ref(),
172/// &resolved_versions,
173/// &HashMap::new(),
174/// &SimpleFormatter,
175/// EcosystemId::Dart,
176/// );
177///
178/// assert_eq!(targets, vec![(PackageName::new("dep-0"), "1.0.0".to_string())]);
179/// ```
180pub fn tier3_license_targets(
181 parse_result: &dyn deps_core::ParseResult,
182 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
183 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
184 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
185 ecosystem_id: EcosystemId,
186) -> Vec<(PackageName, String)> {
187 let mut seen = std::collections::HashSet::new();
188 parse_result
189 .dependencies()
190 .into_iter()
191 .filter(|d| formatter.source_is_public_registry_content(&d.source()))
192 .filter_map(|d| {
193 let normalized = formatter.normalize_package_name(d.name());
194 let version = resolve_in_use_version(
195 d,
196 normalized.as_str(),
197 resolved_versions,
198 Some(resolved_version_candidates),
199 formatter,
200 ecosystem_id,
201 )?;
202 Some((d.name().clone(), version))
203 })
204 .filter(|target| seen.insert(target.clone()))
205 .collect()
206}
207
208/// Fetches every eligible dependency's tier-3 license, for whichever ecosystems override
209/// [`deps_core::Ecosystem::fetch_license`].
210///
211/// A no-op (returns an empty result immediately) for every other ecosystem, via
212/// <code>ecosystem.[license_source](Ecosystem::license_source)().[requires_dedicated_fetch](deps_core::LicenseSource::requires_dedicated_fetch)()</code>
213/// (issue #697) — and likewise a no-op whenever `license_policy` is
214/// [`empty`](deps_core::LicensePolicy::is_empty) (issue #1133 code-review finding #3): the
215/// only consumer of this result is a license-policy evaluation, so an empty policy means
216/// nothing would ever read it. Enforced *inside* this function, not left to each caller to
217/// re-derive, so a future second caller cannot forget the check and silently pay N network
218/// round trips for a result nothing consumes.
219///
220/// Target selection (which dependencies to fetch, at which version) is
221/// [`tier3_license_targets`] — see that function's doc for the filtering rules. The network
222/// dispatch itself is [`fetch_tier3_licenses`] — see that function's doc for
223/// `concurrency`/timeout semantics and [`TierThreeLicenseFetch::timed_out`]'s doc for what
224/// failure modes are (and are not) observable in the result.
225///
226/// **What version each source actually reflects is per-ecosystem, not uniform:** Gradle's POM
227/// fetch and Deno's JSR API are genuinely version-specific (fetched at the dependency's
228/// resolved/in-use version, the `version` passed into [`Ecosystem::fetch_license`]). Dart's
229/// `fetch_license` calls pub.dev's per-*package* `/score` endpoint, which carries no version
230/// parameter at all — it reflects pana's detection on whatever pub.dev last scored, not
231/// necessarily the resolved version. Swift's `fetch_license` calls GitHub's
232/// `GET /repos/{owner}/{repo}`, which reflects the repository's *default branch*, not the
233/// resolved version's tag. [`resolve_in_use_version`] (inside [`tier3_license_targets`]) is
234/// still required as a *gate* for all four (no version resolved means nothing to look up), but
235/// for Dart/Swift it does not pin which version's license is actually returned.
236///
237/// # Examples
238///
239/// ```
240/// use deps_core::{HttpCache, LicensePolicy};
241/// use deps_engine::classify::license::prefetch_tier3_licenses;
242/// use deps_engine::setup::CargoEcosystem;
243/// use std::collections::HashMap;
244/// use std::sync::Arc;
245///
246/// #[tokio::main]
247/// async fn main() {
248/// // Cargo's `license_source()` is the default `RegistryDeclaredSpdx`, whose
249/// // `requires_dedicated_fetch()` is `false` — this returns immediately, with no
250/// // parsed manifest and no network call, mirroring every non-tier-3 ecosystem.
251/// let ecosystem = CargoEcosystem::new(Arc::new(HttpCache::new()));
252/// let parsed = deps_core::test_util::stub_parse_result_with_dependencies(1);
253/// let license_policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
254///
255/// let result = prefetch_tier3_licenses(
256/// &ecosystem,
257/// parsed.as_ref(),
258/// &HashMap::new(),
259/// &HashMap::new(),
260/// &license_policy,
261/// 10,
262/// 4,
263/// )
264/// .await;
265///
266/// assert!(result.licenses.is_empty());
267/// }
268/// ```
269pub async fn prefetch_tier3_licenses(
270 ecosystem: &dyn Ecosystem,
271 parse_result: &dyn deps_core::ParseResult,
272 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
273 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
274 license_policy: &deps_core::LicensePolicy,
275 fetch_timeout_secs: u64,
276 concurrency: usize,
277) -> TierThreeLicenseFetch {
278 if license_policy.is_empty() || !ecosystem.license_source().requires_dedicated_fetch() {
279 return TierThreeLicenseFetch::default();
280 }
281
282 let targets = tier3_license_targets(
283 parse_result,
284 resolved_versions,
285 resolved_version_candidates,
286 ecosystem.formatter(),
287 ecosystem.ecosystem_id(),
288 );
289
290 fetch_tier3_licenses(ecosystem, targets, fetch_timeout_secs, concurrency).await
291}
292
293/// The network-dispatch half of [`prefetch_tier3_licenses`].
294///
295/// Split out so a caller that must not hold a document lock guard across an `.await`
296/// (`deps-lsp`'s `run_license_prefetch`) can compute [`tier3_license_targets`] synchronously
297/// while the guard is held, drop the guard, and only then call this function with the
298/// already-owned target list — mirroring [`crate::classify::osv::build_scan_targets`] (sync,
299/// guard-scoped) versus `OsvClient::scan` (async, called after the guard drops).
300///
301/// `fetch_timeout_secs` is clamped to `TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS..=
302/// TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS` (private constants; see their doc comments in
303/// this module's source) independently of the caller's own timeout semantics.
304///
305/// `concurrency` (clamped to at least 1, mirroring `fetch_latest_versions_parallel`'s
306/// `buffer_unordered(0)`-hangs-forever defense, issue #833) bounds how many per-dependency
307/// fetches run at once — deliberately a caller-supplied parameter, not a hardcoded default
308/// (issue #1133 critic M3): `deps-lsp`'s background pre-fetch and `deps-cli`'s check-gate
309/// call have different latency budgets and no single constant serves both well.
310///
311/// Does **not** check
312/// <code>ecosystem.[license_source](Ecosystem::license_source)().[requires_dedicated_fetch](deps_core::LicenseSource::requires_dedicated_fetch)()</code>
313/// itself — callers that skip [`prefetch_tier3_licenses`]'s convenience wrapper are expected
314/// to have already gated on it (as `run_license_prefetch` does) before ever computing
315/// `targets`, the same way `run_osv_scan_phase_a` gates on `vulnerabilities_enabled` before
316/// calling `build_scan_targets`.
317///
318/// # Examples
319///
320/// ```
321/// use deps_core::PackageName;
322/// use deps_engine::classify::license::fetch_tier3_licenses;
323/// use deps_engine::test_util::TestTier3Ecosystem;
324///
325/// #[tokio::main]
326/// async fn main() {
327/// let ecosystem = TestTier3Ecosystem::returning(vec!["MIT".to_string()]);
328/// let targets = vec![(PackageName::new("pkg"), "1.0.0".to_string())];
329///
330/// let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
331///
332/// assert_eq!(
333/// result.licenses.get(&PackageName::new("pkg")),
334/// Some(&vec!["MIT".to_string()])
335/// );
336/// }
337/// ```
338pub async fn fetch_tier3_licenses(
339 ecosystem: &dyn Ecosystem,
340 targets: Vec<(PackageName, String)>,
341 fetch_timeout_secs: u64,
342 concurrency: usize,
343) -> TierThreeLicenseFetch {
344 use futures::stream::{self, StreamExt};
345
346 if targets.is_empty() {
347 return TierThreeLicenseFetch::default();
348 }
349
350 let timeout_duration = Duration::from_secs(fetch_timeout_secs.clamp(
351 TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS,
352 TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS,
353 ));
354
355 // Each future owns its own `(name, found, timed_out)` result independently (code-review
356 // finding #4) — no shared `Arc<AtomicUsize>`/`Ordering` needed just to count timeouts
357 // across `buffer_unordered`'s concurrent futures; summing after `.collect()` is simpler
358 // with no correctness difference.
359 let results: Vec<(PackageName, Vec<String>, bool)> = stream::iter(targets)
360 .map(|(name, version)| async move {
361 let mut timed_out = false;
362 let found = tokio::time::timeout(
363 timeout_duration,
364 ecosystem.fetch_license(name.as_str(), &version),
365 )
366 .await
367 .unwrap_or_else(|_| {
368 timed_out = true;
369 tracing::debug!(package = %name.for_tracing(), "tier-3 license fetch timed out");
370 Vec::new()
371 });
372 (name, found, timed_out)
373 })
374 // `.max(1)`: same defense as `fetch_latest_versions_parallel` (#833) — a caller
375 // passing `0` must not hang this fetch forever.
376 .buffer_unordered(concurrency.max(1))
377 .collect()
378 .await;
379
380 let mut licenses = HashMap::with_capacity(results.len());
381 let mut timed_out_count = 0usize;
382 for (name, found, timed_out) in results {
383 if timed_out {
384 timed_out_count += 1;
385 }
386 if !found.is_empty() {
387 licenses.insert(name, found);
388 }
389 }
390
391 TierThreeLicenseFetch::new(licenses, timed_out_count)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::test_util::TestTier3Ecosystem;
398 use deps_core::Dependency;
399 use deps_core::VersionReq;
400 use deps_core::lsp_helpers::{
401 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
402 RequirementResolution, SourcePolicy,
403 };
404 use deps_core::parser::DependencySource;
405 use deps_core::position::{Position, Range};
406 use std::any::Any;
407 use std::sync::Arc;
408
409 struct MockFormatter;
410 impl PackageNaming for MockFormatter {}
411 impl PackageRendering for MockFormatter {
412 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
413 version.to_string()
414 }
415 fn package_url(&self, name: &PackageName) -> String {
416 format!("https://example.com/{}", name.as_str())
417 }
418 }
419 impl RequirementResolution for MockFormatter {}
420 impl DiagnosticMessages for MockFormatter {}
421 impl DiagnosticPolicy for MockFormatter {}
422 impl SourcePolicy for MockFormatter {}
423 impl OsvNaming for MockFormatter {}
424
425 struct MockDep {
426 name: PackageName,
427 version_req: Option<VersionReq>,
428 source: DependencySource,
429 }
430
431 impl Dependency for MockDep {
432 fn name(&self) -> &PackageName {
433 &self.name
434 }
435 fn name_range(&self) -> Range {
436 // Distinct per instance, mirroring `osv.rs`'s `MockDep` (`vulnerability_keys`
437 // keys a `HashMap<Range, String>` by `name_range()`).
438 let addr = std::ptr::from_ref(self) as u32;
439 Range::new(Position::new(0, addr), Position::new(0, addr + 1))
440 }
441 fn version_requirement(&self) -> Option<&VersionReq> {
442 self.version_req.as_ref()
443 }
444 fn version_range(&self) -> Option<Range> {
445 None
446 }
447 fn source(&self) -> DependencySource {
448 self.source.clone()
449 }
450 fn as_any(&self) -> &dyn Any {
451 self
452 }
453 }
454
455 struct MockParseResult {
456 deps: Vec<MockDep>,
457 }
458
459 impl deps_core::ParseResult for MockParseResult {
460 fn dependencies(&self) -> Vec<&dyn Dependency> {
461 self.deps.iter().map(|d| d as &dyn Dependency).collect()
462 }
463 fn workspace_root(&self) -> Option<&std::path::Path> {
464 None
465 }
466 fn uri(&self) -> &url::Url {
467 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
468 URI.get_or_init(|| deps_core::test_util::test_uri("/test/pubspec.yaml"))
469 }
470 fn as_any(&self) -> &dyn Any {
471 self
472 }
473 }
474
475 fn dep(name: &str, version_req: &str, source: DependencySource) -> MockDep {
476 MockDep {
477 name: PackageName::new(name),
478 version_req: Some(VersionReq::new(version_req)),
479 source,
480 }
481 }
482
483 #[test]
484 fn tier3_license_targets_includes_public_registry_dep_with_resolved_version() {
485 let parse_result = MockParseResult {
486 deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
487 };
488 let mut resolved = HashMap::new();
489 resolved.insert(PackageName::new("collection"), "1.18.0".into());
490
491 let targets = tier3_license_targets(
492 &parse_result,
493 &resolved,
494 &HashMap::new(),
495 &MockFormatter,
496 EcosystemId::Dart,
497 );
498
499 assert_eq!(
500 targets,
501 vec![(PackageName::new("collection"), "1.18.0".to_string())]
502 );
503 }
504
505 #[test]
506 fn tier3_license_targets_excludes_non_public_registry_source() {
507 // A patched git/path fork must never have its license misattributed to the
508 // upstream registry package it forked from.
509 let parse_result = MockParseResult {
510 deps: vec![dep(
511 "local-fork",
512 "1.0.0",
513 DependencySource::Path {
514 path: "../local-fork".to_string(),
515 },
516 )],
517 };
518 let mut resolved = HashMap::new();
519 resolved.insert(PackageName::new("local-fork"), "1.0.0".into());
520
521 let targets = tier3_license_targets(
522 &parse_result,
523 &resolved,
524 &HashMap::new(),
525 &MockFormatter,
526 EcosystemId::Dart,
527 );
528
529 assert!(targets.is_empty());
530 }
531
532 #[test]
533 fn tier3_license_targets_excludes_dep_with_no_resolvable_in_use_version() {
534 let parse_result = MockParseResult {
535 deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
536 };
537
538 let targets = tier3_license_targets(
539 &parse_result,
540 &HashMap::new(),
541 &HashMap::new(),
542 &MockFormatter,
543 EcosystemId::Dart,
544 );
545
546 assert!(targets.is_empty());
547 }
548
549 #[test]
550 fn tier3_license_targets_never_drops_multiple_eligible_deps() {
551 let parse_result = MockParseResult {
552 deps: vec![
553 dep("collection", "1.18.0", DependencySource::Registry),
554 dep("path", "1.9.0", DependencySource::Registry),
555 ],
556 };
557 let mut resolved = HashMap::new();
558 resolved.insert(PackageName::new("collection"), "1.18.0".into());
559 resolved.insert(PackageName::new("path"), "1.9.0".into());
560
561 let targets = tier3_license_targets(
562 &parse_result,
563 &resolved,
564 &HashMap::new(),
565 &MockFormatter,
566 EcosystemId::Dart,
567 );
568
569 assert_eq!(targets.len(), 2);
570 }
571
572 /// Issue #1133 code-review finding #2: two occurrences of the same name at the same
573 /// in-use version (e.g. Gradle's `implementation` + `testImplementation`) must collapse
574 /// into one target, not fetch the same package+version's license twice.
575 #[test]
576 fn tier3_license_targets_dedups_same_name_and_version() {
577 let parse_result = MockParseResult {
578 deps: vec![
579 dep("okhttp", "4.12.0", DependencySource::Registry),
580 dep("okhttp", "4.12.0", DependencySource::Registry),
581 ],
582 };
583 let mut resolved = HashMap::new();
584 resolved.insert(PackageName::new("okhttp"), "4.12.0".into());
585
586 let targets = tier3_license_targets(
587 &parse_result,
588 &resolved,
589 &HashMap::new(),
590 &MockFormatter,
591 EcosystemId::Gradle,
592 );
593
594 assert_eq!(
595 targets,
596 vec![(PackageName::new("okhttp"), "4.12.0".to_string())]
597 );
598 }
599
600 /// The other half of finding #2: two occurrences of the same name at *different*
601 /// in-use versions must both survive — deduplicating by name alone (rather than
602 /// name+version) would silently drop a legitimately different lookup.
603 #[test]
604 fn tier3_license_targets_keeps_same_name_at_different_versions() {
605 let parse_result = MockParseResult {
606 deps: vec![
607 dep("okhttp", "4.12.0", DependencySource::Registry),
608 dep("okhttp", "4.11.0", DependencySource::Registry),
609 ],
610 };
611
612 let targets = tier3_license_targets(
613 &parse_result,
614 &HashMap::new(),
615 &HashMap::new(),
616 &MockFormatter,
617 EcosystemId::Gradle,
618 );
619
620 assert_eq!(
621 targets,
622 vec![
623 (PackageName::new("okhttp"), "4.12.0".to_string()),
624 (PackageName::new("okhttp"), "4.11.0".to_string()),
625 ]
626 );
627 }
628
629 fn non_empty_license_policy() -> deps_core::LicensePolicy {
630 deps_core::LicensePolicy::new(vec!["MIT".to_string()], vec![])
631 }
632
633 #[tokio::test]
634 async fn prefetch_tier3_licenses_returns_empty_without_network_when_no_targets() {
635 let ecosystem = TestTier3Ecosystem::returning(vec!["MIT".to_string()]);
636 let parse_result = MockParseResult { deps: vec![] };
637
638 let result = prefetch_tier3_licenses(
639 &ecosystem,
640 &parse_result,
641 &HashMap::new(),
642 &HashMap::new(),
643 &non_empty_license_policy(),
644 10,
645 4,
646 )
647 .await;
648
649 assert!(result.licenses.is_empty());
650 assert_eq!(result.timed_out, 0);
651 }
652
653 /// Issue #1133 code-review finding #3: the empty-policy short-circuit must be enforced
654 /// *inside* `prefetch_tier3_licenses`, not left to the caller — a
655 /// `TestTier3Ecosystem::pending()` would hang this test forever if the gate didn't
656 /// short-circuit before ever calling `fetch_license`.
657 #[tokio::test]
658 async fn prefetch_tier3_licenses_no_op_for_empty_license_policy() {
659 let ecosystem = TestTier3Ecosystem::pending();
660 let parse_result = MockParseResult {
661 deps: vec![dep("collection", "1.18.0", DependencySource::Registry)],
662 };
663 let mut resolved = HashMap::new();
664 resolved.insert(PackageName::new("collection"), "1.18.0".into());
665
666 let result = prefetch_tier3_licenses(
667 &ecosystem,
668 &parse_result,
669 &resolved,
670 &HashMap::new(),
671 &deps_core::LicensePolicy::default(),
672 10,
673 4,
674 )
675 .await;
676
677 assert!(result.licenses.is_empty());
678 }
679
680 /// Ecosystem gate (issue #697): a non-tier-3 `license_source()` must short-circuit before
681 /// `tier3_license_targets` is even called — asserted indirectly here by never invoking
682 /// `fetch_license` (a `TestTier3Ecosystem::pending()` would hang the test forever if the
683 /// gate didn't short-circuit; this test's timeout would fail loudly instead).
684 #[tokio::test]
685 async fn prefetch_tier3_licenses_no_op_for_non_tier3_ecosystem() {
686 struct NonTier3(TestTier3Ecosystem);
687 impl deps_core::ecosystem::private::Sealed for NonTier3 {}
688 impl Ecosystem for NonTier3 {
689 fn ecosystem_id(&self) -> EcosystemId {
690 self.0.ecosystem_id()
691 }
692 fn display_name(&self) -> &'static str {
693 self.0.display_name()
694 }
695 fn manifest_filenames(&self) -> &[&'static str] {
696 self.0.manifest_filenames()
697 }
698 fn parse_manifest<'a>(
699 &'a self,
700 content: &'a str,
701 uri: &'a url::Url,
702 ) -> deps_core::ecosystem::BoxFuture<
703 'a,
704 deps_core::Result<Box<dyn deps_core::ParseResult>>,
705 > {
706 self.0.parse_manifest(content, uri)
707 }
708 fn registry(&self) -> Arc<dyn deps_core::Registry> {
709 self.0.registry()
710 }
711 fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
712 self.0.formatter()
713 }
714 fn completion_insert_text(&self, metadata: &dyn deps_core::Metadata) -> Option<String> {
715 self.0.completion_insert_text(metadata)
716 }
717 #[cfg(feature = "lsp-responses")]
718 fn complete_version<'a>(
719 &'a self,
720 request: deps_core::completion::CompletionRequest<'a>,
721 package_name: PackageName,
722 prefix: String,
723 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
724 {
725 self.0.complete_version(request, package_name, prefix)
726 }
727 fn as_any(&self) -> &dyn Any {
728 self
729 }
730 // `license_source()` and `fetch_license` deliberately keep the trait's own
731 // defaults (`RegistryDeclaredSpdx` / unreachable no-op) — this type exists only
732 // to prove the gate, not to override them.
733 }
734
735 let ecosystem = NonTier3(TestTier3Ecosystem::pending());
736 let parse_result = MockParseResult {
737 deps: vec![dep("serde", "1.0.0", DependencySource::Registry)],
738 };
739 let mut resolved = HashMap::new();
740 resolved.insert(PackageName::new("serde"), "1.0.0".into());
741
742 let result = prefetch_tier3_licenses(
743 &ecosystem,
744 &parse_result,
745 &resolved,
746 &HashMap::new(),
747 &non_empty_license_policy(),
748 10,
749 4,
750 )
751 .await;
752
753 assert!(result.licenses.is_empty());
754 }
755
756 #[tokio::test]
757 async fn fetch_tier3_licenses_filters_out_empty_results() {
758 let ecosystem = TestTier3Ecosystem::returning(vec![]);
759 let targets = vec![(PackageName::new("no-license-found"), "1.0.0".to_string())];
760
761 let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
762
763 assert!(
764 result.licenses.is_empty(),
765 "an empty fetch_license result must not appear in the map: {:?}",
766 result.licenses
767 );
768 assert_eq!(result.timed_out, 0);
769 }
770
771 #[tokio::test]
772 async fn fetch_tier3_licenses_dispatches_and_keeps_non_empty_results() {
773 let ecosystem = TestTier3Ecosystem::returning(vec!["Apache-2.0".to_string()]);
774 let targets = vec![(PackageName::new("pkg"), "2.0.0".to_string())];
775
776 let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
777
778 assert_eq!(
779 result.licenses.get(&PackageName::new("pkg")),
780 Some(&vec!["Apache-2.0".to_string()])
781 );
782 assert_eq!(result.timed_out, 0);
783 }
784
785 /// Issue #1133 critic S1: the one failure mode this module *can* observe — its own
786 /// outer timeout firing — must be counted in `timed_out` and must not itself panic or
787 /// hang, even though the per-dependency `fetch_license` future never resolves.
788 ///
789 /// Uses `start_paused` virtual time (not a real sleep) so this test proves the *clamp*
790 /// fired, not just that some timeout eventually did: advancing only 5 virtual seconds for
791 /// a `fetch_timeout_secs` of 1 must **not** yet resolve the future (proving the 1s
792 /// requested timeout was raised to the 10s floor, not used verbatim), while advancing
793 /// past 10s does.
794 #[tokio::test(start_paused = true)]
795 async fn fetch_tier3_licenses_timeout_is_clamped_to_floor_and_counted() {
796 let ecosystem = TestTier3Ecosystem::pending();
797 let targets = vec![(PackageName::new("unreachable-pkg"), "1.0.0".to_string())];
798
799 let mut fut = Box::pin(fetch_tier3_licenses(&ecosystem, targets, 1, 4));
800
801 tokio::time::advance(Duration::from_secs(5)).await;
802 assert!(
803 futures::poll!(&mut fut).is_pending(),
804 "a 1s requested timeout must have been clamped up to the 10s floor, not fired at 5s"
805 );
806
807 tokio::time::advance(Duration::from_secs(6)).await;
808 let result = fut.await;
809
810 assert!(result.licenses.is_empty());
811 assert_eq!(result.timed_out, 1);
812 }
813
814 /// Proves `concurrency` is actually threaded through to `buffer_unordered`, not just
815 /// documented (issue #1133 critic M3) — 10 targets with `concurrency = 3` must never
816 /// observe more than 3 in flight at once.
817 #[tokio::test]
818 async fn fetch_tier3_licenses_respects_concurrency_limit() {
819 use std::sync::atomic::{AtomicUsize, Ordering};
820
821 struct ConcurrencyTrackingEcosystem {
822 current: Arc<AtomicUsize>,
823 max_seen: Arc<AtomicUsize>,
824 }
825 impl deps_core::ecosystem::private::Sealed for ConcurrencyTrackingEcosystem {}
826 impl Ecosystem for ConcurrencyTrackingEcosystem {
827 fn ecosystem_id(&self) -> EcosystemId {
828 EcosystemId::Dart
829 }
830 fn display_name(&self) -> &'static str {
831 "concurrency-tracking"
832 }
833 fn manifest_filenames(&self) -> &[&'static str] {
834 &[]
835 }
836 fn parse_manifest<'a>(
837 &'a self,
838 _content: &'a str,
839 _uri: &'a url::Url,
840 ) -> deps_core::ecosystem::BoxFuture<
841 'a,
842 deps_core::Result<Box<dyn deps_core::ParseResult>>,
843 > {
844 Box::pin(async move { unimplemented!() })
845 }
846 fn registry(&self) -> Arc<dyn deps_core::Registry> {
847 Arc::new(crate::test_util::StubRegistry)
848 }
849 fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
850 &crate::test_util::StubFormatter
851 }
852 fn completion_insert_text(
853 &self,
854 _metadata: &dyn deps_core::Metadata,
855 ) -> Option<String> {
856 None
857 }
858 #[cfg(feature = "lsp-responses")]
859 fn complete_version<'a>(
860 &'a self,
861 _request: deps_core::completion::CompletionRequest<'a>,
862 _package_name: PackageName,
863 _prefix: String,
864 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
865 {
866 Box::pin(async move { deps_core::completion::Completions::default() })
867 }
868 fn fetch_license<'a>(
869 &'a self,
870 _name: &'a str,
871 _version: &'a str,
872 ) -> deps_core::ecosystem::BoxFuture<'a, Vec<String>> {
873 let current = Arc::clone(&self.current);
874 let max_seen = Arc::clone(&self.max_seen);
875 Box::pin(async move {
876 let now = current.fetch_add(1, Ordering::SeqCst) + 1;
877 max_seen.fetch_max(now, Ordering::SeqCst);
878 tokio::time::sleep(Duration::from_millis(30)).await;
879 current.fetch_sub(1, Ordering::SeqCst);
880 vec!["MIT".to_string()]
881 })
882 }
883 fn license_source(&self) -> deps_core::LicenseSource {
884 deps_core::LicenseSource::DetectedSpdx
885 }
886 fn as_any(&self) -> &dyn Any {
887 self
888 }
889 }
890
891 let current = Arc::new(AtomicUsize::new(0));
892 let max_seen = Arc::new(AtomicUsize::new(0));
893 let ecosystem = ConcurrencyTrackingEcosystem {
894 current: Arc::clone(¤t),
895 max_seen: Arc::clone(&max_seen),
896 };
897 let targets: Vec<_> = (0..10)
898 .map(|i| (PackageName::new(format!("pkg-{i}")), "1.0.0".to_string()))
899 .collect();
900
901 fetch_tier3_licenses(&ecosystem, targets, 10, 3).await;
902
903 assert!(
904 max_seen.load(Ordering::SeqCst) <= 3,
905 "concurrency limit of 3 was violated: {} concurrent fetches observed",
906 max_seen.load(Ordering::SeqCst)
907 );
908 }
909}