use aube_codes::errors::{
ERR_AUBE_ADVISORY_CHECK_FAILED, ERR_AUBE_LOW_DOWNLOAD_PACKAGE, ERR_AUBE_MALICIOUS_PACKAGE,
ERR_AUBE_NEW_PACKAGE_NAME, ERR_AUBE_PACKAGE_AGE_CHECK_FAILED, ERR_AUBE_SIMILAR_PACKAGE_NAME,
};
use aube_codes::warnings::{
WARN_AUBE_ADVISORY_CHECK_FAILED, WARN_AUBE_LOW_DOWNLOAD_PACKAGE, WARN_AUBE_NEW_PACKAGE_NAME,
WARN_AUBE_OSV_BLOOM_REFRESH_FAILED, WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
WARN_AUBE_SIMILAR_PACKAGE_NAME,
};
use aube_registry::osv_bloom_client::OsvBloomClient;
use aube_registry::osv_mirror::OsvMirror;
use aube_registry::supply_chain::{
DownloadCount, MaliciousAdvisory, advisory_url, fetch_malicious_advisories,
fetch_malicious_advisories_versioned, fetch_weekly_downloads_with,
};
use aube_settings::resolved::{AdvisoryBloomCheck, AdvisoryCheck, AdvisoryCheckOnInstall};
use miette::miette;
use std::io::{BufRead, IsTerminal, Write};
use std::path::Path;
#[derive(Clone)]
pub(crate) struct ReputationPolicy<'a> {
pub(crate) allow: bool,
pub(crate) prompt: LowDownloadPrompt,
pub(crate) minimum_package_age_minutes: u64,
pub(crate) registry_client: &'a aube_registry::client::RegistryClient,
pub(crate) full_packument_cache: &'a Path,
}
#[derive(Debug, Clone)]
pub(crate) enum LowDownloadPrompt {
Terminal,
Host(crate::commands::install::InstallControl),
}
pub(crate) async fn run_gates(
name_only_advisory_names: &[String],
exact_advisory_pairs: &[(String, String)],
download_names: &[String],
advisory_check: AdvisoryCheck,
low_download_threshold: u64,
reputation_policy: ReputationPolicy<'_>,
allowed_unpopular_globs: &[String],
) -> miette::Result<()> {
if name_only_advisory_names.is_empty()
&& exact_advisory_pairs.is_empty()
&& download_names.is_empty()
{
return Ok(());
}
let gated_reputation_names = if !reputation_policy.allow {
download_names_to_gate(download_names, allowed_unpopular_globs)
} else {
Vec::new()
};
let probe_client = match aube_registry::supply_chain::build_probe_client() {
Ok(client) => Some(client),
Err(e) => {
if matches!(advisory_check, AdvisoryCheck::Off) {
tracing::debug!(
"supply-chain probe client init failed; OSV is off, skipping downloads probe: {e}"
);
} else {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client init failed: {e}"
);
if matches!(advisory_check, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client could not be initialised and `advisoryCheck = required` is set: {e}"
));
}
}
None
}
};
if let Some(client) = &probe_client {
osv_gate(client, name_only_advisory_names, advisory_check).await?;
osv_gate_versioned(
client,
exact_advisory_pairs,
advisory_check,
"refusing to add malicious package(s):",
)
.await?;
}
if !gated_reputation_names.is_empty() {
similar_name_gate(&gated_reputation_names, &reputation_policy.prompt).await?;
if reputation_policy.minimum_package_age_minutes > 0 {
let cutoff = aube_resolver::MinimumReleaseAge {
minutes: reputation_policy.minimum_package_age_minutes,
..Default::default()
}
.cutoff();
package_age_gate(
reputation_policy.registry_client,
reputation_policy.full_packument_cache,
&gated_reputation_names,
reputation_policy.minimum_package_age_minutes,
cutoff.as_deref(),
&reputation_policy.prompt,
)
.await?;
}
if low_download_threshold > 0
&& let Some(client) = &probe_client
{
downloads_gate(
client,
&gated_reputation_names,
low_download_threshold,
&reputation_policy.prompt,
)
.await?;
}
}
Ok(())
}
async fn package_age_gate(
client: &aube_registry::client::RegistryClient,
full_packument_cache: &Path,
names: &[String],
minimum_age_minutes: u64,
cutoff: Option<&str>,
prompt: &LowDownloadPrompt,
) -> miette::Result<()> {
let Some(cutoff) = cutoff else {
return Ok(());
};
for name in names {
let created = verified_package_created(
name,
client
.fetch_packument_with_time_cached(name, full_packument_cache)
.await,
)?;
if !is_new_package_name(&created, cutoff) {
continue;
}
tracing::warn!(
code = WARN_AUBE_NEW_PACKAGE_NAME,
"{name}: package name was first published at {created}"
);
if !confirm_new_package(prompt, name, &created, minimum_age_minutes).await? {
return Err(miette!(
code = ERR_AUBE_NEW_PACKAGE_NAME,
"user aborted `{} {name}`",
aube_util::cmd("add")
));
}
}
Ok(())
}
fn verified_package_created(
name: &str,
result: Result<aube_registry::Packument, aube_registry::Error>,
) -> miette::Result<String> {
match result {
Ok(packument) => packument.time.get("created").cloned().ok_or_else(|| {
miette!(
code = ERR_AUBE_PACKAGE_AGE_CHECK_FAILED,
"npm did not return a creation timestamp for {name}; refusing to bypass `minimumPackageAge`"
)
}),
Err(error) => Err(miette!(
code = ERR_AUBE_PACKAGE_AGE_CHECK_FAILED,
"could not verify the package-name age for {name}; refusing to bypass `minimumPackageAge`: {error}"
)),
}
}
fn is_new_package_name(created: &str, cutoff: &str) -> bool {
created > cutoff
}
#[allow(clippy::too_many_arguments)]
pub async fn run_post_resolve_osv_routing(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
fresh_resolution: bool,
osv_transitive_check: bool,
advisory_check: AdvisoryCheck,
advisory_check_on_install: AdvisoryCheckOnInstall,
advisory_bloom_check: AdvisoryBloomCheck,
advisory_check_every_install: bool,
) -> miette::Result<()> {
let needs_live_api = osv_transitive_check || advisory_check_every_install || fresh_resolution;
if needs_live_api {
if !matches!(advisory_check, AdvisoryCheck::Off) {
run_transitive_osv_gate(cwd, graph, advisory_check).await?;
}
} else if !matches!(advisory_bloom_check, AdvisoryBloomCheck::Off) {
run_transitive_osv_gate_via_bloom(cwd, graph, advisory_bloom_check).await?;
} else if !matches!(advisory_check_on_install, AdvisoryCheckOnInstall::Off) {
run_transitive_osv_gate_via_mirror(cwd, graph, advisory_check_on_install).await?;
}
Ok(())
}
pub async fn run_transitive_osv_gate(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
policy: AdvisoryCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
let pairs = transitive_registry_pairs(cwd, graph);
if pairs.is_empty() {
return Ok(());
}
let client = match aube_registry::supply_chain::build_probe_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client init failed: {e}"
);
if matches!(policy, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"supply-chain probe client could not be initialised and `advisoryCheck = required` is set: {e}"
));
}
return Ok(());
}
};
osv_gate_versioned(
&client,
&pairs,
policy,
"refusing to install malicious package(s):",
)
.await
}
pub async fn run_transitive_osv_gate_via_mirror(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
policy: AdvisoryCheckOnInstall,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheckOnInstall::Off) {
return Ok(());
}
let pairs = transitive_registry_pairs(cwd, graph);
if pairs.is_empty() {
return Ok(());
}
let Some(cache_dir) = aube_store::dirs::cache_dir() else {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror cache dir unavailable (HOME/XDG_CACHE_HOME unset); skipping install-time advisory check"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror cache dir unavailable and `advisoryCheckOnInstall = required` is set"
));
}
return Ok(());
};
let mirror = OsvMirror::open(&cache_dir);
let client = match OsvMirror::build_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror probe client init failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror probe client could not be initialised and `advisoryCheckOnInstall = required` is set: {e}"
));
}
return Ok(());
}
};
if let Err(e) = mirror.refresh_if_stale_default(&client).await {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror refresh failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror refresh failed and `advisoryCheckOnInstall = required` is set: {e}"
));
}
}
let hits = match mirror.lookup_advisories_versioned(&pairs) {
Ok(hits) => hits,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_MIRROR_REFRESH_FAILED,
"OSV mirror lookup failed: {e}"
);
if matches!(policy, AdvisoryCheckOnInstall::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV mirror lookup failed and `advisoryCheckOnInstall = required` is set: {e}"
));
}
return Ok(());
}
};
if hits.is_empty() {
return Ok(());
}
Err(miette!(
code = ERR_AUBE_MALICIOUS_PACKAGE,
"{}",
format_malicious_message(
"refusing to install malicious package(s):",
&hits,
"Set `advisoryCheckOnInstall = off` to bypass (not recommended).",
),
))
}
pub async fn run_transitive_osv_gate_via_bloom(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
policy: AdvisoryBloomCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryBloomCheck::Off) {
return Ok(());
}
let pkgs = transitive_registry_pairs(cwd, graph);
if pkgs.is_empty() {
return Ok(());
}
let Some(cache_dir) = aube_store::dirs::cache_dir() else {
tracing::warn!(
code = WARN_AUBE_OSV_BLOOM_REFRESH_FAILED,
"OSV bloom cache dir unavailable (HOME/XDG_CACHE_HOME unset); skipping bloom check"
);
if matches!(policy, AdvisoryBloomCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV bloom cache dir unavailable and `advisoryBloomCheck = required` is set"
));
}
return Ok(());
};
let bloom_client = OsvBloomClient::open(&cache_dir);
let http = match OsvBloomClient::build_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_BLOOM_REFRESH_FAILED,
"OSV bloom probe client init failed: {e}"
);
if matches!(policy, AdvisoryBloomCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV bloom probe client could not be initialised and `advisoryBloomCheck = required` is set: {e}"
));
}
return Ok(());
}
};
if let Err(e) = bloom_client.refresh_if_stale_default(&http).await {
tracing::warn!(
code = WARN_AUBE_OSV_BLOOM_REFRESH_FAILED,
"OSV bloom refresh failed: {e}"
);
if matches!(policy, AdvisoryBloomCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV bloom refresh failed and `advisoryBloomCheck = required` is set: {e}"
));
}
return Ok(());
}
let bloom_hits = match bloom_client.probe_lockfile(&pkgs) {
Ok(hits) => hits,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_OSV_BLOOM_REFRESH_FAILED,
"OSV bloom probe failed: {e}"
);
if matches!(policy, AdvisoryBloomCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV bloom probe failed and `advisoryBloomCheck = required` is set: {e}"
));
}
return Ok(());
}
};
if bloom_hits.is_empty() {
return Ok(());
}
let live_policy = match policy {
AdvisoryBloomCheck::Required => AdvisoryCheck::Required,
_ => AdvisoryCheck::On,
};
let live_client = match aube_registry::supply_chain::build_probe_client() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"live-OSV probe client init failed during bloom escalation: {e}"
);
if matches!(policy, AdvisoryBloomCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"live-OSV probe client could not be initialised and `advisoryBloomCheck = required` is set: {e}"
));
}
return Ok(());
}
};
osv_gate_versioned_with_bypass(
&live_client,
&bloom_hits,
live_policy,
"refusing to install malicious package(s):",
"advisoryBloomCheck",
)
.await
}
pub fn lockfile_has_new_picks(
cwd: &std::path::Path,
prior: Option<&aube_lockfile::LockfileGraph>,
resolved: &aube_lockfile::LockfileGraph,
) -> bool {
use std::collections::HashSet;
let npm_config = aube_registry::config::NpmConfig::load(cwd);
let prior_pairs: HashSet<(&str, &str)> = prior
.map(|g| {
g.packages
.values()
.filter(|p| p.local_source.is_none())
.map(|p| (p.registry_name(), p.version.as_str()))
.collect()
})
.unwrap_or_default();
resolved
.packages
.values()
.filter(|p| p.local_source.is_none())
.filter(|p| npm_config.is_public_npmjs(p.registry_name()))
.any(|p| !prior_pairs.contains(&(p.registry_name(), p.version.as_str())))
}
fn transitive_registry_pairs(
cwd: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
) -> Vec<(String, String)> {
let npm_config = aube_registry::config::NpmConfig::load(cwd);
let mut pairs: Vec<(String, String)> = graph
.packages
.values()
.filter(|pkg| pkg.local_source.is_none())
.filter(|pkg| npm_config.is_public_npmjs(pkg.registry_name()))
.map(|pkg| (pkg.registry_name().to_string(), pkg.version.clone()))
.collect();
pairs.sort();
pairs.dedup();
pairs
}
fn compile_allowed_unpopular(raw: &[String]) -> Vec<glob::Pattern> {
raw.iter()
.filter_map(|p| match glob::Pattern::new(p) {
Ok(pat) => Some(pat),
Err(e) => {
tracing::warn!("ignoring malformed allowedUnpopularPackages entry `{p}`: {e}");
None
}
})
.collect()
}
fn download_names_to_gate(names: &[String], allowed_unpopular_globs: &[String]) -> Vec<String> {
let patterns = compile_allowed_unpopular(allowed_unpopular_globs);
names
.iter()
.filter(|name| !patterns.iter().any(|pattern| pattern.matches(name)))
.cloned()
.collect()
}
async fn osv_gate(
client: &reqwest::Client,
names: &[String],
policy: AdvisoryCheck,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
handle_osv_result(
fetch_malicious_advisories(client, names).await,
policy,
"refusing to add malicious package(s):",
"advisoryCheck",
)
}
async fn osv_gate_versioned(
client: &reqwest::Client,
pairs: &[(String, String)],
policy: AdvisoryCheck,
refusal_header: &str,
) -> miette::Result<()> {
osv_gate_versioned_with_bypass(client, pairs, policy, refusal_header, "advisoryCheck").await
}
async fn osv_gate_versioned_with_bypass(
client: &reqwest::Client,
pairs: &[(String, String)],
policy: AdvisoryCheck,
refusal_header: &str,
bypass_setting: &str,
) -> miette::Result<()> {
if matches!(policy, AdvisoryCheck::Off) {
return Ok(());
}
handle_osv_result(
fetch_malicious_advisories_versioned(client, pairs).await,
policy,
refusal_header,
bypass_setting,
)
}
fn handle_osv_result(
result: Result<Vec<MaliciousAdvisory>, aube_registry::supply_chain::SupplyChainError>,
policy: AdvisoryCheck,
refusal_header: &str,
bypass_setting: &str,
) -> miette::Result<()> {
match result {
Ok(hits) if hits.is_empty() => Ok(()),
Ok(hits) => Err(miette!(
code = ERR_AUBE_MALICIOUS_PACKAGE,
"{}",
format_malicious_message(
refusal_header,
&hits,
&format!("Set `{bypass_setting} = off` to bypass (not recommended)."),
),
)),
Err(e) => {
tracing::warn!(
code = WARN_AUBE_ADVISORY_CHECK_FAILED,
"OSV advisory check failed: {e}"
);
if matches!(policy, AdvisoryCheck::Required) {
return Err(miette!(
code = ERR_AUBE_ADVISORY_CHECK_FAILED,
"OSV advisory check failed and `{bypass_setting} = required` is set: {e}"
));
}
Ok(())
}
}
}
fn format_malicious_message(header: &str, hits: &[MaliciousAdvisory], footer: &str) -> String {
let mut lines = vec![header.to_string()];
for hit in hits {
let display_name = match &hit.version {
Some(v) => format!("{}@{}", hit.package, v),
None => hit.package.clone(),
};
lines.push(format!(
" - {} ({}: {})",
display_name,
hit.advisory_id,
advisory_url(&hit.advisory_id),
));
}
lines.push(String::new());
lines.push(footer.to_string());
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PackageNameSuggestion {
name: String,
rank: usize,
distance: u8,
}
async fn similar_name_gate(names: &[String], prompt: &LowDownloadPrompt) -> miette::Result<()> {
let corpus = aube_resolver::popular_package_names();
for name in names {
let Some(suggestion) = find_similar_package_name(name, corpus) else {
continue;
};
tracing::warn!(
code = WARN_AUBE_SIMILAR_PACKAGE_NAME,
"{name} resembles {} (popularity rank #{}, edit distance {})",
suggestion.name,
suggestion.rank,
suggestion.distance
);
if !confirm_similar_package(prompt, name, &suggestion).await? {
return Err(miette!(
code = ERR_AUBE_SIMILAR_PACKAGE_NAME,
"user aborted `{} {name}`",
aube_util::cmd("add")
));
}
}
Ok(())
}
fn find_similar_package_name(name: &str, corpus: &str) -> Option<PackageNameSuggestion> {
let mut best: Option<PackageNameSuggestion> = None;
for (index, candidate) in corpus.lines().enumerate() {
if name == candidate {
return None;
}
let Some((requested_part, candidate_part)) = comparable_name_parts(name, candidate) else {
continue;
};
let threshold = if requested_part.len().max(candidate_part.len()) >= 5 {
2
} else {
1
};
let Some(distance) = bounded_damerau_levenshtein(requested_part, candidate_part, threshold)
else {
continue;
};
let suggestion = PackageNameSuggestion {
name: candidate.to_string(),
rank: index + 1,
distance,
};
if best
.as_ref()
.is_none_or(|current| (distance, index) < (current.distance, current.rank - 1))
{
best = Some(suggestion);
}
}
best
}
fn comparable_name_parts<'a>(requested: &'a str, candidate: &'a str) -> Option<(&'a str, &'a str)> {
let requested_scoped = requested
.strip_prefix('@')
.and_then(|name| name.split_once('/'));
let candidate_scoped = candidate
.strip_prefix('@')
.and_then(|name| name.split_once('/'));
match (requested_scoped, candidate_scoped) {
(None, None) => Some((requested, candidate)),
(Some((requested_scope, requested_name)), Some((candidate_scope, candidate_name)))
if requested_scope == candidate_scope =>
{
Some((requested_name, candidate_name))
}
(Some(_), Some(_)) => Some((requested, candidate)),
_ => None,
}
}
fn bounded_damerau_levenshtein(left: &str, right: &str, limit: u8) -> Option<u8> {
const MAX_NAME_LEN: usize = 214;
if !left.is_ascii()
|| !right.is_ascii()
|| left.len() > MAX_NAME_LEN
|| right.len() > MAX_NAME_LEN
|| left.len().abs_diff(right.len()) > usize::from(limit)
{
return None;
}
let left = left.as_bytes();
let right = right.as_bytes();
let mut previous_previous = [0_u16; MAX_NAME_LEN + 1];
let mut previous = [0_u16; MAX_NAME_LEN + 1];
let mut current = [0_u16; MAX_NAME_LEN + 1];
for (j, cell) in previous.iter_mut().take(right.len() + 1).enumerate() {
*cell = j as u16;
}
for i in 1..=left.len() {
current[0] = i as u16;
let mut row_min = current[0];
for j in 1..=right.len() {
let substitution = previous[j - 1] + u16::from(left[i - 1] != right[j - 1]);
current[j] = (previous[j] + 1).min(current[j - 1] + 1).min(substitution);
if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
current[j] = current[j].min(previous_previous[j - 2] + 1);
}
row_min = row_min.min(current[j]);
}
if row_min > u16::from(limit) {
return None;
}
previous_previous = previous;
previous = current;
}
let distance = previous[right.len()];
(distance <= u16::from(limit)).then_some(distance as u8)
}
async fn confirm_similar_package(
prompt: &LowDownloadPrompt,
name: &str,
suggestion: &PackageNameSuggestion,
) -> miette::Result<bool> {
let refusal = || {
miette!(
code = ERR_AUBE_SIMILAR_PACKAGE_NAME,
"refusing to add {name}: did you mean {} (top-100,000 rank #{}, edit distance {})? Pass --allow-low-downloads after verifying the package name.",
suggestion.name,
suggestion.rank,
suggestion.distance
)
};
match prompt {
LowDownloadPrompt::Terminal
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() =>
{
prompt_similar_package(name, suggestion)
}
LowDownloadPrompt::Terminal => Err(refusal()),
LowDownloadPrompt::Host(control) => control
.confirm(
crate::commands::install::InstallPrompt::SimilarPackageName {
package: name.to_string(),
suggested_package: suggestion.name.clone(),
popularity_rank: suggestion.rank,
edit_distance: suggestion.distance,
},
)
.await
.unwrap_or_else(|| Err(refusal())),
}
}
fn prompt_similar_package(name: &str, suggestion: &PackageNameSuggestion) -> miette::Result<bool> {
let mut stderr = std::io::stderr().lock();
writeln!(stderr, " ⚠ {name} resembles a popular package:").ok();
writeln!(
stderr,
" • did you mean {}? (top-100,000 rank #{}, edit distance {})",
suggestion.name, suggestion.rank, suggestion.distance
)
.ok();
write!(stderr, " Continue adding {name}? [y/N] ").ok();
stderr.flush().ok();
drop(stderr);
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).map_err(|e| {
miette!(
code = ERR_AUBE_SIMILAR_PACKAGE_NAME,
"failed to read confirmation: {e}"
)
})?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
async fn downloads_gate(
client: &reqwest::Client,
names: &[String],
threshold: u64,
prompt: &LowDownloadPrompt,
) -> miette::Result<()> {
let mut set: tokio::task::JoinSet<(String, Result<DownloadCount, _>)> =
tokio::task::JoinSet::new();
for name in names {
let client = client.clone();
let name = name.clone();
set.spawn(async move {
let result = fetch_weekly_downloads_with(&client, &name).await;
(name, result)
});
}
let mut by_name: std::collections::HashMap<String, _> =
std::collections::HashMap::with_capacity(names.len());
while let Some(joined) = set.join_next().await {
let (name, result) = match joined {
Ok(pair) => pair,
Err(e) => {
tracing::debug!("downloads probe task join failed: {e}");
continue;
}
};
by_name.insert(name, result);
}
for name in names {
let Some(result) = by_name.remove(name) else {
continue;
};
let count = match result {
Ok(c) => c,
Err(e) => {
tracing::debug!("downloads probe failed for {name}: {e}");
continue;
}
};
let DownloadCount::Known(weekly) = count else {
continue;
};
if weekly >= threshold {
continue;
}
tracing::warn!(
code = WARN_AUBE_LOW_DOWNLOAD_PACKAGE,
"{name}: {weekly} weekly downloads (threshold: {threshold})"
);
if !confirm_low_download(prompt, name, weekly, threshold).await? {
return Err(miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"user aborted `{} {name}`",
aube_util::cmd("add")
));
}
}
Ok(())
}
async fn confirm_low_download(
prompt: &LowDownloadPrompt,
name: &str,
weekly: u64,
threshold: u64,
) -> miette::Result<bool> {
let refusal = || {
miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"refusing to add {name}: only {weekly} weekly downloads (threshold: {threshold}). Pass --allow-low-downloads to bypass, or set `lowDownloadThreshold = 0`."
)
};
match prompt {
LowDownloadPrompt::Terminal
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() =>
{
prompt_continue(name, weekly, threshold)
}
LowDownloadPrompt::Terminal => Err(refusal()),
LowDownloadPrompt::Host(control) => control
.confirm(
crate::commands::install::InstallPrompt::LowDownloadPackage {
package: name.to_string(),
weekly_downloads: weekly,
threshold,
},
)
.await
.unwrap_or_else(|| Err(refusal())),
}
}
fn prompt_continue(name: &str, weekly: u64, threshold: u64) -> miette::Result<bool> {
let mut stderr = std::io::stderr().lock();
writeln!(stderr, " ⚠ {name} looks suspicious:").ok();
writeln!(
stderr,
" • {weekly} downloads last week (threshold: {threshold})"
)
.ok();
write!(stderr, " Continue adding {name}? [y/N] ").ok();
stderr.flush().ok();
drop(stderr);
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).map_err(|e| {
miette!(
code = ERR_AUBE_LOW_DOWNLOAD_PACKAGE,
"failed to read confirmation: {e}"
)
})?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
async fn confirm_new_package(
prompt: &LowDownloadPrompt,
name: &str,
created: &str,
minimum_age_minutes: u64,
) -> miette::Result<bool> {
let refusal = || {
miette!(
code = ERR_AUBE_NEW_PACKAGE_NAME,
"refusing to add {name}: the package name was first published at {created}, within the configured `minimumPackageAge` of {minimum_age_minutes} minutes. Pass --allow-low-downloads to approve this new name explicitly."
)
};
match prompt {
LowDownloadPrompt::Terminal
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() =>
{
prompt_new_package(name, created, minimum_age_minutes)
}
LowDownloadPrompt::Terminal => Err(refusal()),
LowDownloadPrompt::Host(control) => control
.confirm(crate::commands::install::InstallPrompt::NewPackageName {
package: name.to_string(),
created_at: created.to_string(),
minimum_age_minutes,
})
.await
.unwrap_or_else(|| Err(refusal())),
}
}
fn prompt_new_package(name: &str, created: &str, minimum_age_minutes: u64) -> miette::Result<bool> {
let mut stderr = std::io::stderr().lock();
writeln!(stderr, " ⚠ {name} is a newly registered package name:").ok();
writeln!(stderr, " • first published {created}").ok();
writeln!(stderr, " • minimum age: {minimum_age_minutes} minutes").ok();
write!(stderr, " Continue adding {name}? [y/N] ").ok();
stderr.flush().ok();
drop(stderr);
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line).map_err(|e| {
miette!(
code = ERR_AUBE_NEW_PACKAGE_NAME,
"failed to read confirmation: {e}"
)
})?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::install::{
InstallControl, InstallPrompt, InstallPromptFuture, InstallPromptHandler,
};
use std::sync::{Arc, Mutex};
struct RecordingPromptHandler {
answer: bool,
prompts: Mutex<Vec<InstallPrompt>>,
}
impl InstallPromptHandler for RecordingPromptHandler {
fn confirm(&self, prompt: InstallPrompt) -> InstallPromptFuture<'_> {
self.prompts.lock().unwrap().push(prompt);
Box::pin(async move { Ok(self.answer) })
}
}
#[tokio::test]
async fn osv_gate_off_skips_network() {
let client = aube_registry::supply_chain::build_probe_client()
.expect("probe client builder shouldn't fail in tests");
let names = vec!["lodash".to_string()];
assert!(osv_gate(&client, &names, AdvisoryCheck::Off).await.is_ok());
}
#[tokio::test]
async fn run_gates_no_op_on_empty() {
assert!(
run_gates(
&[],
&[],
&[],
AdvisoryCheck::Required,
1000,
ReputationPolicy {
allow: false,
prompt: LowDownloadPrompt::Terminal,
minimum_package_age_minutes: 43_200,
registry_client: &crate::commands::make_client(std::path::Path::new(".")),
full_packument_cache: std::path::Path::new("."),
},
&[],
)
.await
.is_ok()
);
}
#[tokio::test]
async fn embedded_confirmation_is_routed_to_the_host() {
let handler = Arc::new(RecordingPromptHandler {
answer: true,
prompts: Mutex::new(Vec::new()),
});
let prompt =
LowDownloadPrompt::Host(InstallControl::silent().with_prompt_handler(handler.clone()));
assert!(
confirm_low_download(&prompt, "@scope/tiny", 12, 1000)
.await
.unwrap()
);
assert_eq!(
*handler.prompts.lock().unwrap(),
vec![InstallPrompt::LowDownloadPackage {
package: "@scope/tiny".to_string(),
weekly_downloads: 12,
threshold: 1000,
}]
);
}
#[tokio::test]
async fn embedded_confirmation_without_a_handler_fails_closed() {
let prompt = LowDownloadPrompt::Host(InstallControl::silent());
let error = confirm_low_download(&prompt, "tiny", 12, 1000)
.await
.unwrap_err();
assert_eq!(
error.code().map(|code| code.to_string()).as_deref(),
Some(ERR_AUBE_LOW_DOWNLOAD_PACKAGE)
);
}
#[tokio::test]
async fn embedded_new_package_confirmation_is_routed_to_the_host() {
let handler = Arc::new(RecordingPromptHandler {
answer: true,
prompts: Mutex::new(Vec::new()),
});
let prompt =
LowDownloadPrompt::Host(InstallControl::silent().with_prompt_handler(handler.clone()));
assert!(
confirm_new_package(
&prompt,
"plausible-ai-package",
"2026-07-28T10:00:00.000Z",
1440,
)
.await
.unwrap()
);
assert_eq!(
*handler.prompts.lock().unwrap(),
vec![InstallPrompt::NewPackageName {
package: "plausible-ai-package".to_string(),
created_at: "2026-07-28T10:00:00.000Z".to_string(),
minimum_age_minutes: 1440,
}]
);
}
#[tokio::test]
async fn new_package_refusal_names_the_governing_setting() {
let prompt = LowDownloadPrompt::Host(InstallControl::silent());
let error = confirm_new_package(
&prompt,
"plausible-ai-package",
"2026-07-28T10:00:00.000Z",
43_200,
)
.await
.unwrap_err();
assert!(error.to_string().contains("`minimumPackageAge`"));
assert_eq!(
error.code().map(|code| code.to_string()).as_deref(),
Some(ERR_AUBE_NEW_PACKAGE_NAME)
);
}
#[tokio::test]
async fn embedded_similar_name_confirmation_is_routed_to_the_host() {
let handler = Arc::new(RecordingPromptHandler {
answer: true,
prompts: Mutex::new(Vec::new()),
});
let prompt =
LowDownloadPrompt::Host(InstallControl::silent().with_prompt_handler(handler.clone()));
let suggestion = PackageNameSuggestion {
name: "lodash".to_string(),
rank: 12,
distance: 1,
};
assert!(
confirm_similar_package(&prompt, "lodahs", &suggestion)
.await
.unwrap()
);
assert_eq!(
*handler.prompts.lock().unwrap(),
vec![InstallPrompt::SimilarPackageName {
package: "lodahs".to_string(),
suggested_package: "lodash".to_string(),
popularity_rank: 12,
edit_distance: 1,
}]
);
}
#[test]
fn similar_name_detects_adjacent_transposition() {
assert_eq!(
find_similar_package_name("lodahs", "react\nlodash\nexpress\n"),
Some(PackageNameSuggestion {
name: "lodash".to_string(),
rank: 2,
distance: 1,
})
);
}
#[test]
fn bundled_corpus_detects_common_package_typo() {
let suggestion =
find_similar_package_name("lodahs", aube_resolver::popular_package_names())
.expect("lodash should be present in the popularity corpus");
assert_eq!(suggestion.name, "lodash");
assert_eq!(suggestion.distance, 1);
}
#[test]
fn similar_name_compares_basename_within_same_scope() {
assert_eq!(
find_similar_package_name("@babel/parserr", "@types/node\n@babel/parser\n"),
Some(PackageNameSuggestion {
name: "@babel/parser".to_string(),
rank: 2,
distance: 1,
})
);
}
#[test]
fn similar_name_compares_full_name_across_scopes() {
assert_eq!(
find_similar_package_name("@type/node", "@types/node\n"),
Some(PackageNameSuggestion {
name: "@types/node".to_string(),
rank: 1,
distance: 1,
})
);
}
#[test]
fn similar_name_does_not_compare_scoped_with_unscoped() {
assert_eq!(find_similar_package_name("@example/react", "react\n"), None);
}
#[test]
fn similar_name_skips_exact_and_distant_names() {
assert_eq!(
find_similar_package_name("lodash", "lodash\nexpress\n"),
None
);
}
#[test]
fn popular_package_is_not_flagged_by_a_lower_ranked_similar_name() {
assert_eq!(
find_similar_package_name("esbuild", "esbuild\nmsbuild\n"),
None
);
assert_eq!(
find_similar_package_name("@types/node", "@types/node\n@types/code\n"),
None
);
}
#[test]
fn similar_name_prefers_distance_then_popularity_rank() {
assert_eq!(
find_similar_package_name("foobarz", "foobars\nfoobaz\n"),
Some(PackageNameSuggestion {
name: "foobars".to_string(),
rank: 1,
distance: 1,
})
);
}
#[test]
fn exact_allowed_names_skip_reputation_gates() {
let names = vec!["locked[tiny]".to_string(), "new-tiny".to_string()];
let allowed = vec![glob::Pattern::escape("locked[tiny]")];
assert_eq!(
download_names_to_gate(&names, &allowed),
vec!["new-tiny".to_string()]
);
}
#[test]
fn package_name_age_is_strictly_newer_than_cutoff() {
let cutoff = "2026-07-27T00:00:00.000Z";
assert!(is_new_package_name("2026-07-27T00:00:00.001Z", cutoff));
assert!(!is_new_package_name(cutoff, cutoff));
assert!(!is_new_package_name("2026-07-26T23:59:59.999Z", cutoff));
}
#[test]
fn package_age_probe_requires_a_creation_timestamp() {
let error = verified_package_created(
"missing-time",
Ok(aube_registry::Packument {
name: "missing-time".to_string(),
modified: None,
versions: Default::default(),
dist_tags: Default::default(),
time: Default::default(),
}),
)
.unwrap_err();
assert_eq!(
error.code().map(|code| code.to_string()).as_deref(),
Some(ERR_AUBE_PACKAGE_AGE_CHECK_FAILED)
);
}
#[test]
fn package_age_probe_reads_created_from_standard_packument() {
let created = "2026-07-28T10:00:00.000Z";
let value = verified_package_created(
"plausible-ai-package",
Ok(aube_registry::Packument {
name: "plausible-ai-package".to_string(),
modified: None,
versions: Default::default(),
dist_tags: Default::default(),
time: [("created".to_string(), created.to_string())]
.into_iter()
.collect(),
}),
)
.unwrap();
assert_eq!(value, created);
}
#[test]
fn package_age_probe_fails_closed_on_registry_errors() {
let error = verified_package_created(
"unreachable",
Err(aube_registry::Error::NotFound("unreachable".to_string())),
)
.unwrap_err();
assert_eq!(
error.code().map(|code| code.to_string()).as_deref(),
Some(ERR_AUBE_PACKAGE_AGE_CHECK_FAILED)
);
}
#[test]
fn compile_allowed_unpopular_drops_invalid_patterns() {
let pats = compile_allowed_unpopular(&[
"@myorg/*".to_string(),
"[unterminated".to_string(),
"internal-*".to_string(),
]);
assert_eq!(pats.len(), 2);
assert!(pats.iter().any(|p| p.matches("@myorg/foo")));
assert!(pats.iter().any(|p| p.matches("internal-thing")));
assert!(!pats.iter().any(|p| p.matches("public-pkg")));
}
#[test]
fn compile_allowed_unpopular_scope_glob_matches_only_in_scope() {
let pats = compile_allowed_unpopular(&["@myorg/*".to_string()]);
assert!(pats[0].matches("@myorg/utils"));
assert!(pats[0].matches("@myorg/nested-name"));
assert!(!pats[0].matches("@otherorg/utils"));
assert!(!pats[0].matches("myorg-utils"));
}
fn registry_pkg(name: &str, version: &str) -> aube_lockfile::LockedPackage {
aube_lockfile::LockedPackage {
name: name.to_string(),
version: version.to_string(),
..Default::default()
}
}
#[test]
fn transitive_registry_pairs_skips_local_source_entries() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
packages.insert("@workspace/util@1.0.0".to_string(), linked);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(pairs, vec![("lodash".to_string(), "4.17.21".to_string())],);
}
#[test]
fn transitive_registry_pairs_dedups_by_registry_name_and_version() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let mut aliased = registry_pkg("my-alias", "4.17.21");
aliased.alias_of = Some("lodash".to_string());
packages.insert("my-alias@4.17.21".to_string(), aliased);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(pairs, vec![("lodash".to_string(), "4.17.21".to_string())],);
}
#[test]
fn transitive_registry_pairs_keeps_distinct_versions_of_one_name() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"ansi-regex@3.0.1".to_string(),
registry_pkg("ansi-regex", "3.0.1"),
);
packages.insert(
"ansi-regex@6.2.1".to_string(),
registry_pkg("ansi-regex", "6.2.1"),
);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let pairs = transitive_registry_pairs(tmp.path(), &graph);
assert_eq!(
pairs,
vec![
("ansi-regex".to_string(), "3.0.1".to_string()),
("ansi-regex".to_string(), "6.2.1".to_string()),
],
);
}
#[test]
fn format_malicious_message_includes_version_when_present() {
let hits = vec![
MaliciousAdvisory {
package: "ansi-regex".to_string(),
advisory_id: "MAL-2025-46966".to_string(),
version: Some("6.2.1".to_string()),
},
MaliciousAdvisory {
package: "evil".to_string(),
advisory_id: "MAL-9999".to_string(),
version: None,
},
];
let msg = format_malicious_message("header:", &hits, "footer.");
assert!(msg.contains("ansi-regex@6.2.1"));
assert!(msg.contains("MAL-2025-46966"));
assert!(msg.contains("- evil ("), "name-only hit keeps bare name");
assert!(msg.starts_with("header:"));
assert!(msg.ends_with("footer."));
}
#[test]
fn add_exact_pin_refusal_uses_add_header() {
let err = handle_osv_result(
Ok(vec![MaliciousAdvisory {
package: "nx".to_string(),
advisory_id: "MAL-2025-41443".to_string(),
version: Some("20.9.0".to_string()),
}]),
AdvisoryCheck::On,
"refusing to add malicious package(s):",
"advisoryCheck",
)
.expect_err("malicious hit should fail");
let rendered = err.to_string();
assert!(rendered.contains("refusing to add malicious package(s):"));
assert!(!rendered.contains("refusing to install malicious package(s):"));
assert!(rendered.contains("nx@20.9.0"));
}
#[test]
fn lockfile_drift_no_prior_lockfile_is_drift_when_resolved_has_entries() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let resolved = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_no_prior_with_only_workspace_entries_is_not_drift() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
packages.insert("@workspace/util@1.0.0".to_string(), linked);
let resolved = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_empty_resolve_and_no_prior_is_not_drift() {
let resolved = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), None, &resolved));
}
#[test]
fn lockfile_drift_fully_pinned_is_not_drift() {
use std::collections::BTreeMap;
let mut prior_packages = BTreeMap::new();
prior_packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let prior = aube_lockfile::LockfileGraph {
packages: prior_packages.clone(),
..Default::default()
};
let resolved = aube_lockfile::LockfileGraph {
packages: prior_packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[test]
fn lockfile_drift_new_version_is_drift() {
use std::collections::BTreeMap;
let mut prior_packages = BTreeMap::new();
prior_packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let prior = aube_lockfile::LockfileGraph {
packages: prior_packages,
..Default::default()
};
let mut resolved_packages = BTreeMap::new();
resolved_packages.insert(
"lodash@4.17.22".to_string(),
registry_pkg("lodash", "4.17.22"),
);
let resolved = aube_lockfile::LockfileGraph {
packages: resolved_packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[test]
fn lockfile_drift_ignores_local_source_entries() {
use std::collections::BTreeMap;
let mut resolved_packages = BTreeMap::new();
let mut linked = registry_pkg("@workspace/util", "1.0.0");
linked.local_source = Some(aube_lockfile::LocalSource::Link("../util".into()));
resolved_packages.insert("@workspace/util@1.0.0".to_string(), linked);
let resolved = aube_lockfile::LockfileGraph {
packages: resolved_packages,
..Default::default()
};
let prior = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!lockfile_has_new_picks(tmp.path(), Some(&prior), &resolved));
}
#[tokio::test]
async fn run_transitive_osv_gate_off_skips_network() {
let graph = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate(tmp.path(), &graph, AdvisoryCheck::Off)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transitive_osv_gate_via_mirror_off_short_circuits() {
use std::collections::BTreeMap;
let mut packages = BTreeMap::new();
packages.insert(
"lodash@4.17.21".to_string(),
registry_pkg("lodash", "4.17.21"),
);
let graph = aube_lockfile::LockfileGraph {
packages,
..Default::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate_via_mirror(tmp.path(), &graph, AdvisoryCheckOnInstall::Off,)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_transitive_osv_gate_via_mirror_empty_graph_is_noop() {
let graph = aube_lockfile::LockfileGraph::default();
let tmp = tempfile::tempdir().expect("tempdir");
assert!(
run_transitive_osv_gate_via_mirror(tmp.path(), &graph, AdvisoryCheckOnInstall::On,)
.await
.is_ok()
);
}
}