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