pub(crate) mod batcher;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use lru::LruCache;
use reqwest::Client;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use url::Url;
use crate::policy::{self, BlocklistSnapshot, Candidate, Decision, Ecosystem};
const OSV_QUERYBATCH_URL: &str = "https://api.osv.dev/v1/querybatch";
pub(crate) const OSV_BATCH_RECORDS: usize = 256;
pub(crate) const OSV_BATCH_INTERVAL: Duration = Duration::from_millis(10);
pub(crate) const OSV_NEGATIVE_TTL: Duration = Duration::from_secs(30);
pub(crate) const OSV_CHANNEL_CAPACITY: usize = 1024;
pub(crate) const OSV_CACHE_ENTRIES: usize = 65_536;
pub(crate) const OSV_ENQUEUE_TIMEOUT: Duration = Duration::from_millis(50);
pub(crate) type OsvRequest = ((Ecosystem, String, String), oneshot::Sender<bool>);
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum OsvMode {
Enforce,
Diagnostic,
Off,
}
struct CacheEntry {
matched: bool,
expires_at: Instant,
}
pub(crate) struct OsvCache {
entries: Mutex<LruCache<(Ecosystem, String, String), CacheEntry>>,
match_ttl: Duration,
}
impl OsvCache {
fn new(match_ttl: Duration) -> OsvCache {
let capacity = NonZeroUsize::new(OSV_CACHE_ENTRIES).expect("a non-zero bound");
OsvCache {
entries: Mutex::new(LruCache::new(capacity)),
match_ttl,
}
}
fn get(&self, key: &(Ecosystem, String, String)) -> Option<bool> {
let mut entries = self.entries.lock().expect("the OSV cache");
match entries.get(key) {
Some(entry) if entry.expires_at > Instant::now() => return Some(entry.matched),
Some(_) => {}
None => return None,
}
entries.pop(key);
None
}
pub(crate) fn put(&self, key: (Ecosystem, String, String), matched: bool) {
let ttl = if matched {
self.match_ttl
} else {
OSV_NEGATIVE_TTL
};
let mut entries = self.entries.lock().expect("the OSV cache");
entries.put(
key,
CacheEntry {
matched,
expires_at: Instant::now() + ttl,
},
);
}
}
pub struct OsvClient {
tx: mpsc::Sender<OsvRequest>,
cache: Arc<OsvCache>,
request_timeout: Duration,
mode: OsvMode,
}
impl OsvClient {
pub fn new(
client: Client,
cache_ttl: Duration,
request_timeout: Duration,
mode: OsvMode,
shutdown: CancellationToken,
) -> (OsvClient, JoinHandle<()>) {
let url = Url::parse(OSV_QUERYBATCH_URL).expect("a fixed, valid URL");
OsvClient::spawn_with(client, url, cache_ttl, request_timeout, mode, shutdown)
}
pub(crate) fn spawn_with(
client: Client,
url: Url,
cache_ttl: Duration,
request_timeout: Duration,
mode: OsvMode,
shutdown: CancellationToken,
) -> (OsvClient, JoinHandle<()>) {
let (tx, rx) = mpsc::channel(OSV_CHANNEL_CAPACITY);
let cache = Arc::new(OsvCache::new(cache_ttl));
let handle = tokio::spawn(batcher::run(
client,
url,
rx,
request_timeout,
Arc::clone(&cache),
shutdown,
));
(
OsvClient {
tx,
cache,
request_timeout: OSV_BATCH_INTERVAL + request_timeout,
mode,
},
handle,
)
}
pub async fn check(&self, ecosystem: Ecosystem, name: &str, version: &str) -> bool {
let key = (ecosystem, name.to_owned(), version.to_owned());
if let Some(matched) = self.cache.get(&key) {
return matched;
}
let (reply_tx, reply_rx) = oneshot::channel();
let enqueued =
tokio::time::timeout(OSV_ENQUEUE_TIMEOUT, self.tx.send((key.clone(), reply_tx))).await;
if !matches!(enqueued, Ok(Ok(()))) {
self.cache.put(key, false);
return false;
}
tokio::time::timeout(self.request_timeout, reply_rx)
.await
.ok()
.and_then(Result::ok)
.unwrap_or(false)
}
}
#[cfg(feature = "test-support")]
pub fn unreachable_client() -> Client {
let nothing_listens_here: std::net::SocketAddr = ([127, 0, 0, 1], 0).into();
Client::builder()
.resolve("api.osv.dev", nothing_listens_here)
.build()
.expect("a client with a DNS override is always buildable")
}
pub async fn evaluate(
osv: &OsvClient,
snapshot: Option<&BlocklistSnapshot>,
now_utc_micros: i64,
cooldown_seconds: u64,
candidate: &Candidate<'_>,
) -> Decision {
let blind = policy::evaluate(snapshot, now_utc_micros, cooldown_seconds, candidate, false);
if !matches!(blind, Decision::Allow) {
return blind;
}
if osv.mode == OsvMode::Off {
return blind;
}
let matched = osv
.check(candidate.ecosystem, candidate.name, candidate.version)
.await;
if osv.mode == OsvMode::Diagnostic {
if matched {
crate::http::logging::record_osv_diagnostic_match();
}
return blind;
}
policy::evaluate(
snapshot,
now_utc_micros,
cooldown_seconds,
candidate,
matched,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::PublicationTime;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration as StdDuration;
const SHIPPED_TIMEOUT: StdDuration = StdDuration::from_millis(500);
fn candidate<'a>(name: &'a str, version: &'a str) -> Candidate<'a> {
Candidate {
ecosystem: Ecosystem::Npm,
name,
version,
publication: PublicationTime::Upstream(0),
advertised_digests: &[],
pinned_digests: &[],
}
}
#[tokio::test]
async fn check_fails_open_on_full_channel() {
let (tx, _rx) = mpsc::channel(1);
let client = OsvClient {
tx,
cache: Arc::new(OsvCache::new(StdDuration::from_secs(60))),
request_timeout: SHIPPED_TIMEOUT,
mode: OsvMode::Enforce,
};
let (holder_tx, _holder_rx) = oneshot::channel::<bool>();
client
.tx
.try_send((
(Ecosystem::Npm, "filler".to_owned(), "0.0.0".to_owned()),
holder_tx,
))
.expect("the one slot is free before this send");
let started = Instant::now();
let matched = client.check(Ecosystem::Npm, "left-pad", "1.0.0").await;
let elapsed = started.elapsed();
assert!(!matched, "a full channel fails open");
assert!(
elapsed < SHIPPED_TIMEOUT,
"the enqueue must fail open at OSV_ENQUEUE_TIMEOUT, not block; took {elapsed:?}"
);
}
#[tokio::test]
async fn check_fails_open_on_batcher_timeout() {
let (url, _server, _count) = osv_server(200, true, StdDuration::from_secs(60)).await;
let request_timeout = StdDuration::from_millis(50);
let (client, handle, shutdown) = spawn(
url,
OsvMode::Enforce,
StdDuration::from_secs(60),
request_timeout,
);
let started = Instant::now();
let matched = client.check(Ecosystem::Npm, "left-pad", "1.0.0").await;
let elapsed = started.elapsed();
assert!(!matched, "a hung collector fails the check open");
assert!(
elapsed < OSV_BATCH_INTERVAL + request_timeout + StdDuration::from_millis(200),
"bounded by the batch interval plus request_timeout; took {elapsed:?}"
);
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn osv_client_new_spawns_one_batcher_task() {
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::new(
Client::new(),
StdDuration::from_secs(60),
StdDuration::from_millis(50),
OsvMode::Enforce,
shutdown.clone(),
);
assert!(!handle.is_finished(), "the batcher is running");
drop(client);
handle.await.expect("the one spawned batcher task joins");
drop(shutdown);
}
pub(super) async fn osv_server(
status: u16,
matched: bool,
delay: StdDuration,
) -> (Url, wiremock::MockServer, Arc<AtomicUsize>) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let count = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&count);
Mock::given(method("POST"))
.and(path("/v1/querybatch"))
.respond_with(move |request: &wiremock::Request| {
counted.fetch_add(1, Ordering::SeqCst);
let body: serde_json::Value =
serde_json::from_slice(&request.body).expect("a JSON batch body");
let queries = body["queries"].as_array().expect("a queries array");
let vulns = if matched {
serde_json::json!([{"id": "MAL-2026-0001"}])
} else {
serde_json::json!([])
};
let results: Vec<serde_json::Value> = queries
.iter()
.map(|_| serde_json::json!({"vulns": vulns}))
.collect();
ResponseTemplate::new(status)
.set_body_json(serde_json::json!({"results": results}))
.set_delay(delay)
})
.mount(&server)
.await;
let url = Url::parse(&format!("{}/v1/querybatch", server.uri())).unwrap();
(url, server, count)
}
async fn mode_server(matched: bool) -> (Url, wiremock::MockServer, Arc<AtomicUsize>) {
osv_server(200, matched, StdDuration::ZERO).await
}
fn spawn(
url: Url,
mode: OsvMode,
cache_ttl: StdDuration,
request_timeout: StdDuration,
) -> (OsvClient, JoinHandle<()>, CancellationToken) {
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
cache_ttl,
request_timeout,
mode,
shutdown.clone(),
);
(client, handle, shutdown)
}
fn clean_snapshot(now: i64) -> BlocklistSnapshot {
crate::policy::BlocklistSnapshot::parse_and_validate(
br#"{"schema_version":1,"revision":1,
"generated_at":"1970-01-01T00:00:00Z","expires_at":"2099-01-01T00:00:00Z",
"blocked_packages":[],"blocked_hashes":[]}"#,
now,
)
.expect("a valid snapshot")
}
async fn evaluate_in(mode: OsvMode, snapshot: &BlocklistSnapshot) -> (Decision, usize) {
let (url, _server, count) = mode_server(true).await;
let (client, handle, shutdown) =
spawn(url, mode, StdDuration::from_secs(60), SHIPPED_TIMEOUT);
let decision = evaluate(
&client,
Some(snapshot),
10_000_000,
0,
&candidate("left-pad", "1.0.0"),
)
.await;
shutdown.cancel();
let _ = handle.await;
(decision, count.load(Ordering::SeqCst))
}
#[tokio::test]
async fn evaluate_skips_osv_when_producer_already_denies() {
let blocked = crate::policy::BlocklistSnapshot::parse_and_validate(
br#"{"schema_version":1,"revision":1,
"generated_at":"1970-01-01T00:00:00Z","expires_at":"2099-01-01T00:00:00Z",
"blocked_packages":[{"ecosystem":"npm","name":"left-pad","version":null,"reason":"malware"}],
"blocked_hashes":[]}"#,
10_000_000,
)
.expect("a valid snapshot");
let (decision, asked) = evaluate_in(OsvMode::Enforce, &blocked).await;
assert_eq!(
decision,
Decision::Deny(crate::policy::DenyReason::BlockedPackage)
);
assert_eq!(
asked, 0,
"OSV is not asked about an already-denied candidate"
);
}
#[tokio::test]
async fn evaluate_enforce_mode_denies_on_match() {
let (decision, asked) = evaluate_in(OsvMode::Enforce, &clean_snapshot(10_000_000)).await;
assert_eq!(
decision,
Decision::Deny(crate::policy::DenyReason::BlockedByOsv)
);
assert_eq!(asked, 1, "a clean candidate asks OSV exactly once");
}
#[tokio::test]
async fn evaluate_off_mode_never_calls_check() {
let (decision, asked) = evaluate_in(OsvMode::Off, &clean_snapshot(10_000_000)).await;
assert_eq!(decision, Decision::Allow);
assert_eq!(asked, 0, "off mode never asks OSV");
}
#[tokio::test]
async fn evaluate_diagnostic_mode_asks_osv_and_allows_on_match() {
let (decision, asked) = evaluate_in(OsvMode::Diagnostic, &clean_snapshot(10_000_000)).await;
assert_eq!(decision, Decision::Allow, "diagnostic mode never denies");
assert_eq!(asked, 1, "diagnostic mode still asks OSV");
}
#[tokio::test]
async fn evaluate_diagnostic_mode_allows_without_match() {
let (url, _server, count) = mode_server(false).await;
let (client, handle, shutdown) = spawn(
url,
OsvMode::Diagnostic,
StdDuration::from_secs(60),
SHIPPED_TIMEOUT,
);
let now = 10_000_000;
let decision = evaluate(
&client,
Some(&clean_snapshot(now)),
now,
0,
&candidate("left-pad", "1.0.0"),
)
.await;
assert_eq!(decision, Decision::Allow);
assert_eq!(count.load(Ordering::SeqCst), 1);
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn a_cached_answer_needs_no_second_round_trip() {
let (url, _server, count) = mode_server(true).await;
let (client, handle, shutdown) = spawn(
url,
OsvMode::Enforce,
StdDuration::from_secs(60),
SHIPPED_TIMEOUT,
);
assert!(client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert!(client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert_eq!(
count.load(Ordering::SeqCst),
1,
"the second answer is the cached one"
);
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn an_expired_answer_is_asked_again() {
let (url, _server, count) = mode_server(true).await;
let cache_ttl = StdDuration::from_millis(50);
let (client, handle, shutdown) = spawn(url, OsvMode::Enforce, cache_ttl, SHIPPED_TIMEOUT);
assert!(client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
tokio::time::sleep(cache_ttl * 2).await;
assert!(client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert_eq!(count.load(Ordering::SeqCst), 2);
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn a_failed_lookup_fails_open_and_is_not_retried_at_once() {
let (url, _server, count) = osv_server(500, true, StdDuration::ZERO).await;
let (client, handle, shutdown) = spawn(
url,
OsvMode::Enforce,
StdDuration::from_secs(60),
SHIPPED_TIMEOUT,
);
assert!(!client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert!(!client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert_eq!(count.load(Ordering::SeqCst), 1, "the failure was cached");
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn the_cache_forgets_its_oldest_answer_past_its_bound() {
let (url, server, _count) = mode_server(true).await;
let (client, handle, shutdown) = spawn(
url,
OsvMode::Enforce,
StdDuration::from_secs(600),
SHIPPED_TIMEOUT,
);
let versions: Vec<String> = (0..=OSV_CACHE_ENTRIES).map(|n| n.to_string()).collect();
for chunk in versions.chunks(OSV_BATCH_RECORDS) {
let answers = futures_util::future::join_all(
chunk
.iter()
.map(|version| client.check(Ecosystem::Npm, "left-pad", version)),
)
.await;
assert!(
answers.into_iter().all(|matched| matched),
"every lookup was answered"
);
}
let asked = server.received_requests().await.expect("recorded").len();
assert!(client.check(Ecosystem::Npm, "left-pad", "0").await);
let asked_again = server.received_requests().await.expect("recorded").len();
assert_eq!(asked_again, asked + 1, "the oldest answer was evicted");
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn an_answer_nobody_waited_for_is_still_cached() {
let (url, _server, count) = osv_server(200, true, StdDuration::from_millis(100)).await;
let (client, handle, shutdown) = spawn(
url,
OsvMode::Enforce,
StdDuration::from_secs(60),
SHIPPED_TIMEOUT,
);
let abandoned = tokio::time::timeout(
StdDuration::from_millis(30),
client.check(Ecosystem::Npm, "left-pad", "1.0.0"),
)
.await;
assert!(abandoned.is_err(), "the first caller gave up mid-flight");
tokio::time::sleep(StdDuration::from_millis(300)).await;
assert!(client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
assert_eq!(count.load(Ordering::SeqCst), 1, "answered from the cache");
shutdown.cancel();
let _ = handle.await;
}
fn minimal_config(data_dir: &std::path::Path) -> crate::config::Config {
let toml = format!(
"listen = \"127.0.0.1:0\"\n\
public_url = \"https://packages.example.org\"\n\
data_dir = \"{dir}\"\n\
blocklist_file = \"{dir}/blocklist.json\"\n\
cooldown_seconds = 0\n\
metadata_ttl_seconds = 300\n\
blocklist_poll_seconds = 5\n\
cache_max_bytes = 1048576\n\
memory_cache_max_bytes = 1048576\n\
max_artifact_bytes = 1048576\n\
max_metadata_bytes = 1048576\n\
max_blocklist_bytes = 1048576\n\
max_upstream_requests = 4\n\
max_artifact_downloads = 4\n\
max_active_requests = 4\n",
dir = data_dir.display(),
);
crate::config::Config::from_toml_str(&toml).expect("a minimal valid config")
}
async fn start_minimal_app(data_dir: &std::path::Path) -> crate::Running {
crate::App::start(crate::AppDeps {
config: minimal_config(data_dir),
clock: std::sync::Arc::new(crate::clock::SystemClock),
transport: std::sync::Arc::new(crate::upstream::ReqwestTransport::production()),
origins: crate::upstream::OriginSet::production(),
osv_client: unreachable_client(),
osv_base_url: None,
})
.await
.expect("the app starts with a minimal config")
}
#[tokio::test]
async fn app_start_constructs_one_osv_client_and_spawns_its_batcher() {
let data_dir = tempfile::tempdir().expect("a temporary data directory");
let running = start_minimal_app(data_dir.path()).await;
assert_eq!(
running.background_task_count(),
3,
"exactly one task beyond the poller and the maintenance pass"
);
assert!(
!running
.app()
.osv
.check(Ecosystem::Npm, "left-pad", "1.0.0")
.await,
"the wired-in client still answers checks"
);
running.shutdown().await.expect("a clean shutdown");
}
#[tokio::test]
async fn running_shutdown_joins_the_batcher_task() {
let data_dir = tempfile::tempdir().expect("a temporary data directory");
let running = start_minimal_app(data_dir.path()).await;
tokio::time::timeout(StdDuration::from_secs(5), running.shutdown())
.await
.expect("shutdown, and therefore the batcher's join, completes promptly")
.expect("a clean shutdown");
}
}