Skip to main content

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(&current),
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}