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::signals.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!(
179/// targets,
180/// vec![(PackageName::new("dep-0"), ConcreteVersion::from("1.0.0"))]
181/// );
182/// ```
183pub fn tier3_license_targets(
184 parse_result: &dyn deps_core::ParseResult,
185 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
186 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
187 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
188 ecosystem_id: EcosystemId,
189) -> Vec<(PackageName, ConcreteVersion)> {
190 let mut seen = std::collections::HashSet::new();
191 parse_result
192 .dependencies()
193 .into_iter()
194 .filter(|d| formatter.source_is_public_registry_content(&d.source()))
195 .filter_map(|d| {
196 let normalized = formatter.normalize_package_name(d.name());
197 let version = resolve_in_use_version(
198 d,
199 normalized.as_str(),
200 resolved_versions,
201 Some(resolved_version_candidates),
202 formatter,
203 ecosystem_id,
204 )?;
205 Some((d.name().clone(), version))
206 })
207 .filter(|target| seen.insert(target.clone()))
208 .collect()
209}
210
211/// Fetches every eligible dependency's tier-3 license, for whichever ecosystems override
212/// [`deps_core::Ecosystem::fetch_license`].
213///
214/// A no-op (returns an empty result immediately) for every other ecosystem, via
215/// <code>ecosystem.[license_source](Ecosystem::license_source)().[requires_dedicated_fetch](deps_core::LicenseSource::requires_dedicated_fetch)()</code>
216/// (issue #697) — and likewise a no-op whenever `license_policy` is
217/// [`empty`](deps_core::LicensePolicy::is_empty) (issue #1133 code-review finding #3): the
218/// only consumer of this result is a license-policy evaluation, so an empty policy means
219/// nothing would ever read it. Enforced *inside* this function, not left to each caller to
220/// re-derive, so a future second caller cannot forget the check and silently pay N network
221/// round trips for a result nothing consumes.
222///
223/// Target selection (which dependencies to fetch, at which version) is
224/// [`tier3_license_targets`] — see that function's doc for the filtering rules. The network
225/// dispatch itself is [`fetch_tier3_licenses`] — see that function's doc for
226/// `concurrency`/timeout semantics and [`TierThreeLicenseFetch::timed_out`]'s doc for what
227/// failure modes are (and are not) observable in the result.
228///
229/// **What version each source actually reflects is per-ecosystem, not uniform:** Gradle's POM
230/// fetch and Deno's JSR API are genuinely version-specific (fetched at the dependency's
231/// resolved/in-use version, the `version` passed into [`Ecosystem::fetch_license`]). Dart's
232/// `fetch_license` calls pub.dev's per-*package* `/score` endpoint, which carries no version
233/// parameter at all — it reflects pana's detection on whatever pub.dev last scored, not
234/// necessarily the resolved version. Swift's `fetch_license` calls GitHub's
235/// `GET /repos/{owner}/{repo}`, which reflects the repository's *default branch*, not the
236/// resolved version's tag. [`resolve_in_use_version`] (inside [`tier3_license_targets`]) is
237/// still required as a *gate* for all four (no version resolved means nothing to look up), but
238/// for Dart/Swift it does not pin which version's license is actually returned.
239///
240/// # Examples
241///
242/// ```
243/// use deps_core::{HttpCache, LicensePolicy};
244/// use deps_engine::classify::license::prefetch_tier3_licenses;
245/// use deps_engine::setup::CargoEcosystem;
246/// use std::collections::HashMap;
247/// use std::sync::Arc;
248///
249/// #[tokio::main]
250/// async fn main() {
251/// // Cargo's `license_source()` is the default `RegistryDeclaredSpdx`, whose
252/// // `requires_dedicated_fetch()` is `false` — this returns immediately, with no
253/// // parsed manifest and no network call, mirroring every non-tier-3 ecosystem.
254/// let ecosystem = CargoEcosystem::new(Arc::new(HttpCache::new()));
255/// let parsed = deps_core::test_util::stub_parse_result_with_dependencies(1);
256/// let license_policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
257///
258/// let result = prefetch_tier3_licenses(
259/// &ecosystem,
260/// parsed.as_ref(),
261/// &HashMap::new(),
262/// &HashMap::new(),
263/// &license_policy,
264/// 10,
265/// 4,
266/// )
267/// .await;
268///
269/// assert!(result.licenses.is_empty());
270/// }
271/// ```
272pub async fn prefetch_tier3_licenses(
273 ecosystem: &dyn Ecosystem,
274 parse_result: &dyn deps_core::ParseResult,
275 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
276 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
277 license_policy: &deps_core::LicensePolicy,
278 fetch_timeout_secs: u64,
279 concurrency: usize,
280) -> TierThreeLicenseFetch {
281 if license_policy.is_empty() || !ecosystem.license_source().requires_dedicated_fetch() {
282 return TierThreeLicenseFetch::default();
283 }
284
285 let targets = tier3_license_targets(
286 parse_result,
287 resolved_versions,
288 resolved_version_candidates,
289 ecosystem.formatter(),
290 ecosystem.ecosystem_id(),
291 );
292
293 fetch_tier3_licenses(ecosystem, targets, fetch_timeout_secs, concurrency).await
294}
295
296/// The network-dispatch half of [`prefetch_tier3_licenses`].
297///
298/// Split out so a caller that must not hold a document lock guard across an `.await`
299/// (`deps-lsp`'s `run_license_prefetch`) can compute [`tier3_license_targets`] synchronously
300/// while the guard is held, drop the guard, and only then call this function with the
301/// already-owned target list — mirroring [`crate::classify::osv::build_scan_targets`] (sync,
302/// guard-scoped) versus `OsvClient::scan` (async, called after the guard drops).
303///
304/// `fetch_timeout_secs` is clamped to `TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS..=
305/// TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS` (private constants; see their doc comments in
306/// this module's source) independently of the caller's own timeout semantics.
307///
308/// `concurrency` (clamped to at least 1, mirroring `fetch_latest_versions_parallel`'s
309/// `buffer_unordered(0)`-hangs-forever defense, issue #833) bounds how many per-dependency
310/// fetches run at once — deliberately a caller-supplied parameter, not a hardcoded default
311/// (issue #1133 critic M3): `deps-lsp`'s background pre-fetch and `deps-cli`'s check-gate
312/// call have different latency budgets and no single constant serves both well.
313///
314/// Does **not** check
315/// <code>ecosystem.[license_source](Ecosystem::license_source)().[requires_dedicated_fetch](deps_core::LicenseSource::requires_dedicated_fetch)()</code>
316/// itself — callers that skip [`prefetch_tier3_licenses`]'s convenience wrapper are expected
317/// to have already gated on it (as `run_license_prefetch` does) before ever computing
318/// `targets`, the same way `run_osv_scan_phase_a` gates on `vulnerabilities_enabled` before
319/// calling `build_scan_targets`.
320///
321/// # Examples
322///
323/// ```
324/// use deps_core::{ConcreteVersion, PackageName};
325/// use deps_engine::classify::license::fetch_tier3_licenses;
326/// use deps_engine::test_util::TestTier3Ecosystem;
327///
328/// #[tokio::main]
329/// async fn main() {
330/// let ecosystem = TestTier3Ecosystem::returning(vec!["MIT".to_string()]);
331/// let targets = vec![(PackageName::new("pkg"), ConcreteVersion::from("1.0.0"))];
332///
333/// let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
334///
335/// assert_eq!(
336/// result.licenses.get(&PackageName::new("pkg")),
337/// Some(&vec!["MIT".to_string()])
338/// );
339/// }
340/// ```
341pub async fn fetch_tier3_licenses(
342 ecosystem: &dyn Ecosystem,
343 targets: Vec<(PackageName, ConcreteVersion)>,
344 fetch_timeout_secs: u64,
345 concurrency: usize,
346) -> TierThreeLicenseFetch {
347 use futures::stream::{self, StreamExt};
348
349 if targets.is_empty() {
350 return TierThreeLicenseFetch::default();
351 }
352
353 let timeout_duration = Duration::from_secs(fetch_timeout_secs.clamp(
354 TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS,
355 TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS,
356 ));
357
358 // Each future owns its own `(name, found, timed_out)` result independently (code-review
359 // finding #4) — no shared `Arc<AtomicUsize>`/`Ordering` needed just to count timeouts
360 // across `buffer_unordered`'s concurrent futures; summing after `.collect()` is simpler
361 // with no correctness difference.
362 let results: Vec<(PackageName, Vec<String>, bool)> = stream::iter(targets)
363 .map(|(name, version)| async move {
364 let mut timed_out = false;
365 let found = tokio::time::timeout(
366 timeout_duration,
367 ecosystem.fetch_license(&name, &version),
368 )
369 .await
370 .unwrap_or_else(|_| {
371 timed_out = true;
372 tracing::debug!(package = %name.for_tracing(), "tier-3 license fetch timed out");
373 Vec::new()
374 });
375 (name, found, timed_out)
376 })
377 // `.max(1)`: same defense as `fetch_latest_versions_parallel` (#833) — a caller
378 // passing `0` must not hang this fetch forever.
379 .buffer_unordered(concurrency.max(1))
380 .collect()
381 .await;
382
383 let mut licenses = HashMap::with_capacity(results.len());
384 let mut timed_out_count = 0usize;
385 for (name, found, timed_out) in results {
386 if timed_out {
387 timed_out_count += 1;
388 }
389 if !found.is_empty() {
390 licenses.insert(name, found);
391 }
392 }
393
394 TierThreeLicenseFetch::new(licenses, timed_out_count)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::test_util::TestTier3Ecosystem;
401 use deps_core::Dependency;
402 use deps_core::VersionReq;
403 use deps_core::parser::DependencySource;
404 use deps_core::position::{Position, Range};
405 use deps_core::test_util::StubFormatter;
406 use std::any::Any;
407 use std::sync::Arc;
408
409 struct MockDep {
410 name: PackageName,
411 version_req: Option<VersionReq>,
412 source: DependencySource,
413 }
414
415 impl Dependency for MockDep {
416 fn name(&self) -> &PackageName {
417 &self.name
418 }
419 fn name_range(&self) -> Range {
420 // Distinct per instance, mirroring `osv.rs`'s `MockDep` (`vulnerability_keys`
421 // keys a `HashMap<Range, String>` by `name_range()`).
422 let addr = std::ptr::from_ref(self) as u32;
423 Range::new(Position::new(0, addr), Position::new(0, addr + 1))
424 }
425 fn version_requirement(&self) -> Option<&VersionReq> {
426 self.version_req.as_ref()
427 }
428 fn version_range(&self) -> Option<Range> {
429 None
430 }
431 fn source(&self) -> DependencySource {
432 self.source.clone()
433 }
434 fn as_any(&self) -> &dyn Any {
435 self
436 }
437 }
438
439 struct MockParseResult {
440 deps: Vec<MockDep>,
441 }
442
443 impl deps_core::ParseResult for MockParseResult {
444 fn dependencies(&self) -> Vec<&dyn Dependency> {
445 self.deps.iter().map(|d| d as &dyn Dependency).collect()
446 }
447 fn workspace_root(&self) -> Option<&std::path::Path> {
448 None
449 }
450 fn uri(&self) -> &url::Url {
451 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
452 URI.get_or_init(|| deps_core::test_util::test_uri("/test/pubspec.yaml"))
453 }
454 fn as_any(&self) -> &dyn Any {
455 self
456 }
457 }
458
459 fn dep(name: &str, version_req: &str, source: DependencySource) -> MockDep {
460 MockDep {
461 name: PackageName::new(name),
462 version_req: Some(VersionReq::new(version_req)),
463 source,
464 }
465 }
466
467 #[test]
468 fn tier3_license_targets_includes_public_registry_dep_with_resolved_version() {
469 let parse_result = MockParseResult {
470 deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
471 };
472 let mut resolved = HashMap::new();
473 resolved.insert(PackageName::new("collection"), "1.18.0".into());
474
475 let targets = tier3_license_targets(
476 &parse_result,
477 &resolved,
478 &HashMap::new(),
479 &StubFormatter::DEFAULT,
480 EcosystemId::Dart,
481 );
482
483 assert_eq!(
484 targets,
485 vec![(
486 PackageName::new("collection"),
487 ConcreteVersion::from("1.18.0")
488 )]
489 );
490 }
491
492 #[test]
493 fn tier3_license_targets_excludes_non_public_registry_source() {
494 // A patched git/path fork must never have its license misattributed to the
495 // upstream registry package it forked from.
496 let parse_result = MockParseResult {
497 deps: vec![dep(
498 "local-fork",
499 "1.0.0",
500 DependencySource::Path {
501 path: "../local-fork".to_string(),
502 },
503 )],
504 };
505 let mut resolved = HashMap::new();
506 resolved.insert(PackageName::new("local-fork"), "1.0.0".into());
507
508 let targets = tier3_license_targets(
509 &parse_result,
510 &resolved,
511 &HashMap::new(),
512 &StubFormatter::DEFAULT,
513 EcosystemId::Dart,
514 );
515
516 assert!(targets.is_empty());
517 }
518
519 #[test]
520 fn tier3_license_targets_excludes_dep_with_no_resolvable_in_use_version() {
521 let parse_result = MockParseResult {
522 deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
523 };
524
525 let targets = tier3_license_targets(
526 &parse_result,
527 &HashMap::new(),
528 &HashMap::new(),
529 &StubFormatter::DEFAULT,
530 EcosystemId::Dart,
531 );
532
533 assert!(targets.is_empty());
534 }
535
536 #[test]
537 fn tier3_license_targets_never_drops_multiple_eligible_deps() {
538 let parse_result = MockParseResult {
539 deps: vec![
540 dep("collection", "1.18.0", DependencySource::Registry),
541 dep("path", "1.9.0", DependencySource::Registry),
542 ],
543 };
544 let mut resolved = HashMap::new();
545 resolved.insert(PackageName::new("collection"), "1.18.0".into());
546 resolved.insert(PackageName::new("path"), "1.9.0".into());
547
548 let targets = tier3_license_targets(
549 &parse_result,
550 &resolved,
551 &HashMap::new(),
552 &StubFormatter::DEFAULT,
553 EcosystemId::Dart,
554 );
555
556 assert_eq!(targets.len(), 2);
557 }
558
559 /// Issue #1133 code-review finding #2: two occurrences of the same name at the same
560 /// in-use version (e.g. Gradle's `implementation` + `testImplementation`) must collapse
561 /// into one target, not fetch the same package+version's license twice.
562 #[test]
563 fn tier3_license_targets_dedups_same_name_and_version() {
564 let parse_result = MockParseResult {
565 deps: vec![
566 dep("okhttp", "4.12.0", DependencySource::Registry),
567 dep("okhttp", "4.12.0", DependencySource::Registry),
568 ],
569 };
570 let mut resolved = HashMap::new();
571 resolved.insert(PackageName::new("okhttp"), "4.12.0".into());
572
573 let targets = tier3_license_targets(
574 &parse_result,
575 &resolved,
576 &HashMap::new(),
577 &StubFormatter::DEFAULT,
578 EcosystemId::Gradle,
579 );
580
581 assert_eq!(
582 targets,
583 vec![(PackageName::new("okhttp"), ConcreteVersion::from("4.12.0"))]
584 );
585 }
586
587 /// The other half of finding #2: two occurrences of the same name at *different*
588 /// in-use versions must both survive — deduplicating by name alone (rather than
589 /// name+version) would silently drop a legitimately different lookup.
590 #[test]
591 fn tier3_license_targets_keeps_same_name_at_different_versions() {
592 let parse_result = MockParseResult {
593 deps: vec![
594 dep("okhttp", "4.12.0", DependencySource::Registry),
595 dep("okhttp", "4.11.0", DependencySource::Registry),
596 ],
597 };
598
599 let targets = tier3_license_targets(
600 &parse_result,
601 &HashMap::new(),
602 &HashMap::new(),
603 &StubFormatter::DEFAULT,
604 EcosystemId::Gradle,
605 );
606
607 assert_eq!(
608 targets,
609 vec![
610 (PackageName::new("okhttp"), ConcreteVersion::from("4.12.0")),
611 (PackageName::new("okhttp"), ConcreteVersion::from("4.11.0")),
612 ]
613 );
614 }
615
616 fn non_empty_license_policy() -> deps_core::LicensePolicy {
617 deps_core::LicensePolicy::new(vec!["MIT".to_string()], vec![])
618 }
619
620 #[tokio::test]
621 async fn prefetch_tier3_licenses_returns_empty_without_network_when_no_targets() {
622 let ecosystem = TestTier3Ecosystem::returning(vec!["MIT".to_string()]);
623 let parse_result = MockParseResult { deps: vec![] };
624
625 let result = prefetch_tier3_licenses(
626 &ecosystem,
627 &parse_result,
628 &HashMap::new(),
629 &HashMap::new(),
630 &non_empty_license_policy(),
631 10,
632 4,
633 )
634 .await;
635
636 assert!(result.licenses.is_empty());
637 assert_eq!(result.timed_out, 0);
638 }
639
640 /// Issue #1133 code-review finding #3: the empty-policy short-circuit must be enforced
641 /// *inside* `prefetch_tier3_licenses`, not left to the caller — a
642 /// `TestTier3Ecosystem::pending()` would hang this test forever if the gate didn't
643 /// short-circuit before ever calling `fetch_license`.
644 #[tokio::test]
645 async fn prefetch_tier3_licenses_no_op_for_empty_license_policy() {
646 let ecosystem = TestTier3Ecosystem::pending();
647 let parse_result = MockParseResult {
648 deps: vec![dep("collection", "1.18.0", DependencySource::Registry)],
649 };
650 let mut resolved = HashMap::new();
651 resolved.insert(PackageName::new("collection"), "1.18.0".into());
652
653 let result = prefetch_tier3_licenses(
654 &ecosystem,
655 &parse_result,
656 &resolved,
657 &HashMap::new(),
658 &deps_core::LicensePolicy::default(),
659 10,
660 4,
661 )
662 .await;
663
664 assert!(result.licenses.is_empty());
665 }
666
667 /// Ecosystem gate (issue #697): a non-tier-3 `license_source()` must short-circuit before
668 /// `tier3_license_targets` is even called — asserted indirectly here by never invoking
669 /// `fetch_license` (a `TestTier3Ecosystem::pending()` would hang the test forever if the
670 /// gate didn't short-circuit; this test's timeout would fail loudly instead).
671 #[tokio::test]
672 async fn prefetch_tier3_licenses_no_op_for_non_tier3_ecosystem() {
673 struct NonTier3(TestTier3Ecosystem);
674 impl deps_core::ecosystem::private::Sealed for NonTier3 {}
675 impl Ecosystem for NonTier3 {
676 fn ecosystem_id(&self) -> EcosystemId {
677 self.0.ecosystem_id()
678 }
679 fn display_name(&self) -> &'static str {
680 self.0.display_name()
681 }
682 fn manifest_filenames(&self) -> &[&'static str] {
683 self.0.manifest_filenames()
684 }
685 fn parse_manifest<'a>(
686 &'a self,
687 content: &'a str,
688 uri: &'a url::Url,
689 ) -> deps_core::ecosystem::BoxFuture<
690 'a,
691 deps_core::Result<Box<dyn deps_core::ParseResult>>,
692 > {
693 self.0.parse_manifest(content, uri)
694 }
695 fn registry(&self) -> Arc<dyn deps_core::Registry> {
696 self.0.registry()
697 }
698 fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
699 self.0.formatter()
700 }
701 fn completion_insert_text(&self, metadata: &dyn deps_core::Metadata) -> Option<String> {
702 self.0.completion_insert_text(metadata)
703 }
704 #[cfg(feature = "lsp-responses")]
705 fn complete_version<'a>(
706 &'a self,
707 request: deps_core::completion::CompletionRequest<'a>,
708 package_name: PackageName,
709 prefix: String,
710 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
711 {
712 self.0.complete_version(request, package_name, prefix)
713 }
714 fn as_any(&self) -> &dyn Any {
715 self
716 }
717 // `license_source()` and `fetch_license` deliberately keep the trait's own
718 // defaults (`RegistryDeclaredSpdx` / unreachable no-op) — this type exists only
719 // to prove the gate, not to override them.
720 }
721
722 let ecosystem = NonTier3(TestTier3Ecosystem::pending());
723 let parse_result = MockParseResult {
724 deps: vec![dep("serde", "1.0.0", DependencySource::Registry)],
725 };
726 let mut resolved = HashMap::new();
727 resolved.insert(PackageName::new("serde"), "1.0.0".into());
728
729 let result = prefetch_tier3_licenses(
730 &ecosystem,
731 &parse_result,
732 &resolved,
733 &HashMap::new(),
734 &non_empty_license_policy(),
735 10,
736 4,
737 )
738 .await;
739
740 assert!(result.licenses.is_empty());
741 }
742
743 #[tokio::test]
744 async fn fetch_tier3_licenses_filters_out_empty_results() {
745 let ecosystem = TestTier3Ecosystem::returning(vec![]);
746 let targets = vec![(
747 PackageName::new("no-license-found"),
748 ConcreteVersion::from("1.0.0"),
749 )];
750
751 let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
752
753 assert!(
754 result.licenses.is_empty(),
755 "an empty fetch_license result must not appear in the map: {:?}",
756 result.licenses
757 );
758 assert_eq!(result.timed_out, 0);
759 }
760
761 #[tokio::test]
762 async fn fetch_tier3_licenses_dispatches_and_keeps_non_empty_results() {
763 let ecosystem = TestTier3Ecosystem::returning(vec!["Apache-2.0".to_string()]);
764 let targets = vec![(PackageName::new("pkg"), ConcreteVersion::from("2.0.0"))];
765
766 let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
767
768 assert_eq!(
769 result.licenses.get(&PackageName::new("pkg")),
770 Some(&vec!["Apache-2.0".to_string()])
771 );
772 assert_eq!(result.timed_out, 0);
773 }
774
775 /// Issue #1133 critic S1: the one failure mode this module *can* observe — its own
776 /// outer timeout firing — must be counted in `timed_out` and must not itself panic or
777 /// hang, even though the per-dependency `fetch_license` future never resolves.
778 ///
779 /// Uses `start_paused` virtual time (not a real sleep) so this test proves the *clamp*
780 /// fired, not just that some timeout eventually did: advancing only 5 virtual seconds for
781 /// a `fetch_timeout_secs` of 1 must **not** yet resolve the future (proving the 1s
782 /// requested timeout was raised to the 10s floor, not used verbatim), while advancing
783 /// past 10s does.
784 #[tokio::test(start_paused = true)]
785 async fn fetch_tier3_licenses_timeout_is_clamped_to_floor_and_counted() {
786 let ecosystem = TestTier3Ecosystem::pending();
787 let targets = vec![(
788 PackageName::new("unreachable-pkg"),
789 ConcreteVersion::from("1.0.0"),
790 )];
791
792 let mut fut = Box::pin(fetch_tier3_licenses(&ecosystem, targets, 1, 4));
793
794 tokio::time::advance(Duration::from_secs(5)).await;
795 assert!(
796 futures::poll!(&mut fut).is_pending(),
797 "a 1s requested timeout must have been clamped up to the 10s floor, not fired at 5s"
798 );
799
800 tokio::time::advance(Duration::from_secs(6)).await;
801 let result = fut.await;
802
803 assert!(result.licenses.is_empty());
804 assert_eq!(result.timed_out, 1);
805 }
806
807 /// Proves `concurrency` is actually threaded through to `buffer_unordered`, not just
808 /// documented (issue #1133 critic M3) — 10 targets with `concurrency = 3` must never
809 /// observe more than 3 in flight at once.
810 #[tokio::test]
811 async fn fetch_tier3_licenses_respects_concurrency_limit() {
812 use std::sync::atomic::{AtomicUsize, Ordering};
813
814 struct ConcurrencyTrackingEcosystem {
815 current: Arc<AtomicUsize>,
816 max_seen: Arc<AtomicUsize>,
817 }
818 impl deps_core::ecosystem::private::Sealed for ConcurrencyTrackingEcosystem {}
819 impl Ecosystem for ConcurrencyTrackingEcosystem {
820 fn ecosystem_id(&self) -> EcosystemId {
821 EcosystemId::Dart
822 }
823 fn display_name(&self) -> &'static str {
824 "concurrency-tracking"
825 }
826 fn manifest_filenames(&self) -> &[&'static str] {
827 &[]
828 }
829 fn parse_manifest<'a>(
830 &'a self,
831 _content: &'a str,
832 _uri: &'a url::Url,
833 ) -> deps_core::ecosystem::BoxFuture<
834 'a,
835 deps_core::Result<Box<dyn deps_core::ParseResult>>,
836 > {
837 Box::pin(async move { unimplemented!() })
838 }
839 fn registry(&self) -> Arc<dyn deps_core::Registry> {
840 Arc::new(crate::test_util::StubRegistry)
841 }
842 fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
843 &StubFormatter::DEFAULT
844 }
845 fn completion_insert_text(
846 &self,
847 _metadata: &dyn deps_core::Metadata,
848 ) -> Option<String> {
849 None
850 }
851 #[cfg(feature = "lsp-responses")]
852 fn complete_version<'a>(
853 &'a self,
854 _request: deps_core::completion::CompletionRequest<'a>,
855 _package_name: PackageName,
856 _prefix: String,
857 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
858 {
859 Box::pin(async move { deps_core::completion::Completions::default() })
860 }
861 fn fetch_license<'a>(
862 &'a self,
863 _name: &'a PackageName,
864 _version: &'a ConcreteVersion,
865 ) -> deps_core::ecosystem::BoxFuture<'a, Vec<String>> {
866 let current = Arc::clone(&self.current);
867 let max_seen = Arc::clone(&self.max_seen);
868 Box::pin(async move {
869 let now = current.fetch_add(1, Ordering::SeqCst) + 1;
870 max_seen.fetch_max(now, Ordering::SeqCst);
871 tokio::time::sleep(Duration::from_millis(30)).await;
872 current.fetch_sub(1, Ordering::SeqCst);
873 vec!["MIT".to_string()]
874 })
875 }
876 fn license_source(&self) -> deps_core::LicenseSource {
877 deps_core::LicenseSource::DetectedSpdx
878 }
879 fn as_any(&self) -> &dyn Any {
880 self
881 }
882 }
883
884 let current = Arc::new(AtomicUsize::new(0));
885 let max_seen = Arc::new(AtomicUsize::new(0));
886 let ecosystem = ConcurrencyTrackingEcosystem {
887 current: Arc::clone(¤t),
888 max_seen: Arc::clone(&max_seen),
889 };
890 let targets: Vec<_> = (0..10)
891 .map(|i| {
892 (
893 PackageName::new(format!("pkg-{i}")),
894 ConcreteVersion::from("1.0.0"),
895 )
896 })
897 .collect();
898
899 fetch_tier3_licenses(&ecosystem, targets, 10, 3).await;
900
901 assert!(
902 max_seen.load(Ordering::SeqCst) <= 3,
903 "concurrency limit of 3 was violated: {} concurrent fetches observed",
904 max_seen.load(Ordering::SeqCst)
905 );
906 }
907}