use deps_core::ConcreteVersion;
use deps_core::Ecosystem;
use deps_core::EcosystemId;
use deps_core::PackageName;
use deps_core::lsp_helpers::resolve_in_use_version;
use std::collections::HashMap;
use std::time::Duration;
const TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS: u64 = 30;
const TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS: u64 = 10;
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TierThreeLicenseFetch {
pub licenses: HashMap<PackageName, Vec<String>>,
pub timed_out: usize,
}
impl TierThreeLicenseFetch {
#[must_use]
pub const fn new(licenses: HashMap<PackageName, Vec<String>>, timed_out: usize) -> Self {
Self {
licenses,
timed_out,
}
}
}
pub fn tier3_license_targets(
parse_result: &dyn deps_core::ParseResult,
resolved_versions: &HashMap<PackageName, ConcreteVersion>,
resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
ecosystem_id: EcosystemId,
) -> Vec<(PackageName, ConcreteVersion)> {
let mut seen = std::collections::HashSet::new();
parse_result
.dependencies()
.into_iter()
.filter(|d| formatter.source_is_public_registry_content(&d.source()))
.filter_map(|d| {
let normalized = formatter.normalize_package_name(d.name());
let version = resolve_in_use_version(
d,
normalized.as_str(),
resolved_versions,
Some(resolved_version_candidates),
formatter,
ecosystem_id,
)?;
Some((d.name().clone(), version))
})
.filter(|target| seen.insert(target.clone()))
.collect()
}
pub async fn prefetch_tier3_licenses(
ecosystem: &dyn Ecosystem,
parse_result: &dyn deps_core::ParseResult,
resolved_versions: &HashMap<PackageName, ConcreteVersion>,
resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
license_policy: &deps_core::LicensePolicy,
fetch_timeout_secs: u64,
concurrency: usize,
) -> TierThreeLicenseFetch {
if license_policy.is_empty() || !ecosystem.license_source().requires_dedicated_fetch() {
return TierThreeLicenseFetch::default();
}
let targets = tier3_license_targets(
parse_result,
resolved_versions,
resolved_version_candidates,
ecosystem.formatter(),
ecosystem.ecosystem_id(),
);
fetch_tier3_licenses(ecosystem, targets, fetch_timeout_secs, concurrency).await
}
pub async fn fetch_tier3_licenses(
ecosystem: &dyn Ecosystem,
targets: Vec<(PackageName, ConcreteVersion)>,
fetch_timeout_secs: u64,
concurrency: usize,
) -> TierThreeLicenseFetch {
use futures::stream::{self, StreamExt};
if targets.is_empty() {
return TierThreeLicenseFetch::default();
}
let timeout_duration = Duration::from_secs(fetch_timeout_secs.clamp(
TIER3_LICENSE_PREFETCH_TIMEOUT_FLOOR_SECS,
TIER3_LICENSE_PREFETCH_TIMEOUT_CEILING_SECS,
));
let results: Vec<(PackageName, Vec<String>, bool)> = stream::iter(targets)
.map(|(name, version)| async move {
let mut timed_out = false;
let found = tokio::time::timeout(
timeout_duration,
ecosystem.fetch_license(&name, &version),
)
.await
.unwrap_or_else(|_| {
timed_out = true;
tracing::debug!(package = %name.for_tracing(), "tier-3 license fetch timed out");
Vec::new()
});
(name, found, timed_out)
})
.buffer_unordered(concurrency.max(1))
.collect()
.await;
let mut licenses = HashMap::with_capacity(results.len());
let mut timed_out_count = 0usize;
for (name, found, timed_out) in results {
if timed_out {
timed_out_count += 1;
}
if !found.is_empty() {
licenses.insert(name, found);
}
}
TierThreeLicenseFetch::new(licenses, timed_out_count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TestTier3Ecosystem;
use deps_core::Dependency;
use deps_core::VersionReq;
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::test_util::StubFormatter;
use std::any::Any;
use std::sync::Arc;
struct MockDep {
name: PackageName,
version_req: Option<VersionReq>,
source: DependencySource,
}
impl Dependency for MockDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
let addr = std::ptr::from_ref(self) as u32;
Range::new(Position::new(0, addr), Position::new(0, addr + 1))
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
None
}
fn source(&self) -> DependencySource {
self.source.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct MockParseResult {
deps: Vec<MockDep>,
}
impl deps_core::ParseResult for MockParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &url::Url {
static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
URI.get_or_init(|| deps_core::test_util::test_uri("/test/pubspec.yaml"))
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn dep(name: &str, version_req: &str, source: DependencySource) -> MockDep {
MockDep {
name: PackageName::new(name),
version_req: Some(VersionReq::new(version_req)),
source,
}
}
#[test]
fn tier3_license_targets_includes_public_registry_dep_with_resolved_version() {
let parse_result = MockParseResult {
deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("collection"), "1.18.0".into());
let targets = tier3_license_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Dart,
);
assert_eq!(
targets,
vec![(
PackageName::new("collection"),
ConcreteVersion::from("1.18.0")
)]
);
}
#[test]
fn tier3_license_targets_excludes_non_public_registry_source() {
let parse_result = MockParseResult {
deps: vec![dep(
"local-fork",
"1.0.0",
DependencySource::Path {
path: "../local-fork".to_string(),
},
)],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("local-fork"), "1.0.0".into());
let targets = tier3_license_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Dart,
);
assert!(targets.is_empty());
}
#[test]
fn tier3_license_targets_excludes_dep_with_no_resolvable_in_use_version() {
let parse_result = MockParseResult {
deps: vec![dep("collection", "^1.0", DependencySource::Registry)],
};
let targets = tier3_license_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Dart,
);
assert!(targets.is_empty());
}
#[test]
fn tier3_license_targets_never_drops_multiple_eligible_deps() {
let parse_result = MockParseResult {
deps: vec![
dep("collection", "1.18.0", DependencySource::Registry),
dep("path", "1.9.0", DependencySource::Registry),
],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("collection"), "1.18.0".into());
resolved.insert(PackageName::new("path"), "1.9.0".into());
let targets = tier3_license_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Dart,
);
assert_eq!(targets.len(), 2);
}
#[test]
fn tier3_license_targets_dedups_same_name_and_version() {
let parse_result = MockParseResult {
deps: vec![
dep("okhttp", "4.12.0", DependencySource::Registry),
dep("okhttp", "4.12.0", DependencySource::Registry),
],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("okhttp"), "4.12.0".into());
let targets = tier3_license_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Gradle,
);
assert_eq!(
targets,
vec![(PackageName::new("okhttp"), ConcreteVersion::from("4.12.0"))]
);
}
#[test]
fn tier3_license_targets_keeps_same_name_at_different_versions() {
let parse_result = MockParseResult {
deps: vec![
dep("okhttp", "4.12.0", DependencySource::Registry),
dep("okhttp", "4.11.0", DependencySource::Registry),
],
};
let targets = tier3_license_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Gradle,
);
assert_eq!(
targets,
vec![
(PackageName::new("okhttp"), ConcreteVersion::from("4.12.0")),
(PackageName::new("okhttp"), ConcreteVersion::from("4.11.0")),
]
);
}
fn non_empty_license_policy() -> deps_core::LicensePolicy {
deps_core::LicensePolicy::new(vec!["MIT".to_string()], vec![])
}
#[tokio::test]
async fn prefetch_tier3_licenses_returns_empty_without_network_when_no_targets() {
let ecosystem = TestTier3Ecosystem::returning(vec!["MIT".to_string()]);
let parse_result = MockParseResult { deps: vec![] };
let result = prefetch_tier3_licenses(
&ecosystem,
&parse_result,
&HashMap::new(),
&HashMap::new(),
&non_empty_license_policy(),
10,
4,
)
.await;
assert!(result.licenses.is_empty());
assert_eq!(result.timed_out, 0);
}
#[tokio::test]
async fn prefetch_tier3_licenses_no_op_for_empty_license_policy() {
let ecosystem = TestTier3Ecosystem::pending();
let parse_result = MockParseResult {
deps: vec![dep("collection", "1.18.0", DependencySource::Registry)],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("collection"), "1.18.0".into());
let result = prefetch_tier3_licenses(
&ecosystem,
&parse_result,
&resolved,
&HashMap::new(),
&deps_core::LicensePolicy::default(),
10,
4,
)
.await;
assert!(result.licenses.is_empty());
}
#[tokio::test]
async fn prefetch_tier3_licenses_no_op_for_non_tier3_ecosystem() {
struct NonTier3(TestTier3Ecosystem);
impl deps_core::ecosystem::private::Sealed for NonTier3 {}
impl Ecosystem for NonTier3 {
fn ecosystem_id(&self) -> EcosystemId {
self.0.ecosystem_id()
}
fn display_name(&self) -> &'static str {
self.0.display_name()
}
fn manifest_filenames(&self) -> &[&'static str] {
self.0.manifest_filenames()
}
fn parse_manifest<'a>(
&'a self,
content: &'a str,
uri: &'a url::Url,
) -> deps_core::ecosystem::BoxFuture<
'a,
deps_core::Result<Box<dyn deps_core::ParseResult>>,
> {
self.0.parse_manifest(content, uri)
}
fn registry(&self) -> Arc<dyn deps_core::Registry> {
self.0.registry()
}
fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
self.0.formatter()
}
fn completion_insert_text(&self, metadata: &dyn deps_core::Metadata) -> Option<String> {
self.0.completion_insert_text(metadata)
}
#[cfg(feature = "lsp-responses")]
fn complete_version<'a>(
&'a self,
request: deps_core::completion::CompletionRequest<'a>,
package_name: PackageName,
prefix: String,
) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
{
self.0.complete_version(request, package_name, prefix)
}
fn as_any(&self) -> &dyn Any {
self
}
}
let ecosystem = NonTier3(TestTier3Ecosystem::pending());
let parse_result = MockParseResult {
deps: vec![dep("serde", "1.0.0", DependencySource::Registry)],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("serde"), "1.0.0".into());
let result = prefetch_tier3_licenses(
&ecosystem,
&parse_result,
&resolved,
&HashMap::new(),
&non_empty_license_policy(),
10,
4,
)
.await;
assert!(result.licenses.is_empty());
}
#[tokio::test]
async fn fetch_tier3_licenses_filters_out_empty_results() {
let ecosystem = TestTier3Ecosystem::returning(vec![]);
let targets = vec![(
PackageName::new("no-license-found"),
ConcreteVersion::from("1.0.0"),
)];
let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
assert!(
result.licenses.is_empty(),
"an empty fetch_license result must not appear in the map: {:?}",
result.licenses
);
assert_eq!(result.timed_out, 0);
}
#[tokio::test]
async fn fetch_tier3_licenses_dispatches_and_keeps_non_empty_results() {
let ecosystem = TestTier3Ecosystem::returning(vec!["Apache-2.0".to_string()]);
let targets = vec![(PackageName::new("pkg"), ConcreteVersion::from("2.0.0"))];
let result = fetch_tier3_licenses(&ecosystem, targets, 10, 4).await;
assert_eq!(
result.licenses.get(&PackageName::new("pkg")),
Some(&vec!["Apache-2.0".to_string()])
);
assert_eq!(result.timed_out, 0);
}
#[tokio::test(start_paused = true)]
async fn fetch_tier3_licenses_timeout_is_clamped_to_floor_and_counted() {
let ecosystem = TestTier3Ecosystem::pending();
let targets = vec![(
PackageName::new("unreachable-pkg"),
ConcreteVersion::from("1.0.0"),
)];
let mut fut = Box::pin(fetch_tier3_licenses(&ecosystem, targets, 1, 4));
tokio::time::advance(Duration::from_secs(5)).await;
assert!(
futures::poll!(&mut fut).is_pending(),
"a 1s requested timeout must have been clamped up to the 10s floor, not fired at 5s"
);
tokio::time::advance(Duration::from_secs(6)).await;
let result = fut.await;
assert!(result.licenses.is_empty());
assert_eq!(result.timed_out, 1);
}
#[tokio::test]
async fn fetch_tier3_licenses_respects_concurrency_limit() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct ConcurrencyTrackingEcosystem {
current: Arc<AtomicUsize>,
max_seen: Arc<AtomicUsize>,
}
impl deps_core::ecosystem::private::Sealed for ConcurrencyTrackingEcosystem {}
impl Ecosystem for ConcurrencyTrackingEcosystem {
fn ecosystem_id(&self) -> EcosystemId {
EcosystemId::Dart
}
fn display_name(&self) -> &'static str {
"concurrency-tracking"
}
fn manifest_filenames(&self) -> &[&'static str] {
&[]
}
fn parse_manifest<'a>(
&'a self,
_content: &'a str,
_uri: &'a url::Url,
) -> deps_core::ecosystem::BoxFuture<
'a,
deps_core::Result<Box<dyn deps_core::ParseResult>>,
> {
Box::pin(async move { unimplemented!() })
}
fn registry(&self) -> Arc<dyn deps_core::Registry> {
Arc::new(crate::test_util::StubRegistry)
}
fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
&StubFormatter::DEFAULT
}
fn completion_insert_text(
&self,
_metadata: &dyn deps_core::Metadata,
) -> Option<String> {
None
}
#[cfg(feature = "lsp-responses")]
fn complete_version<'a>(
&'a self,
_request: deps_core::completion::CompletionRequest<'a>,
_package_name: PackageName,
_prefix: String,
) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
{
Box::pin(async move { deps_core::completion::Completions::default() })
}
fn fetch_license<'a>(
&'a self,
_name: &'a PackageName,
_version: &'a ConcreteVersion,
) -> deps_core::ecosystem::BoxFuture<'a, Vec<String>> {
let current = Arc::clone(&self.current);
let max_seen = Arc::clone(&self.max_seen);
Box::pin(async move {
let now = current.fetch_add(1, Ordering::SeqCst) + 1;
max_seen.fetch_max(now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(30)).await;
current.fetch_sub(1, Ordering::SeqCst);
vec!["MIT".to_string()]
})
}
fn license_source(&self) -> deps_core::LicenseSource {
deps_core::LicenseSource::DetectedSpdx
}
fn as_any(&self) -> &dyn Any {
self
}
}
let current = Arc::new(AtomicUsize::new(0));
let max_seen = Arc::new(AtomicUsize::new(0));
let ecosystem = ConcurrencyTrackingEcosystem {
current: Arc::clone(¤t),
max_seen: Arc::clone(&max_seen),
};
let targets: Vec<_> = (0..10)
.map(|i| {
(
PackageName::new(format!("pkg-{i}")),
ConcreteVersion::from("1.0.0"),
)
})
.collect();
fetch_tier3_licenses(&ecosystem, targets, 10, 3).await;
assert!(
max_seen.load(Ordering::SeqCst) <= 3,
"concurrency limit of 3 was violated: {} concurrent fetches observed",
max_seen.load(Ordering::SeqCst)
);
}
}