pub(crate) mod batcher;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
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_secs(2);
pub(crate) const OSV_NEGATIVE_TTL: Duration = Duration::from_secs(30);
pub(crate) const OSV_CHANNEL_CAPACITY: usize = 1024;
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,
}
struct OsvCache {
entries: Mutex<HashMap<(Ecosystem, String, String), CacheEntry>>,
}
impl OsvCache {
fn new() -> OsvCache {
OsvCache {
entries: Mutex::new(HashMap::new()),
}
}
fn get(&self, key: &(Ecosystem, String, String)) -> Option<bool> {
let entries = self.entries.lock().expect("the OSV cache");
entries
.get(key)
.filter(|entry| entry.expires_at > Instant::now())
.map(|entry| entry.matched)
}
fn put(&self, key: (Ecosystem, String, String), matched: bool, ttl: Duration) {
let mut entries = self.entries.lock().expect("the OSV cache");
entries.insert(
key,
CacheEntry {
matched,
expires_at: Instant::now() + ttl,
},
);
}
}
pub struct OsvClient {
tx: mpsc::Sender<OsvRequest>,
cache: OsvCache,
cache_ttl: Duration,
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 handle = tokio::spawn(batcher::run(client, url, rx, request_timeout, shutdown));
(
OsvClient {
tx,
cache: OsvCache::new(),
cache_ttl,
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 matched = self.resolve(key.clone()).await;
let ttl = if matched {
self.cache_ttl
} else {
OSV_NEGATIVE_TTL
};
self.cache.put(key, matched, ttl);
matched
}
async fn resolve(&self, key: (Ecosystem, String, String)) -> bool {
let (reply_tx, reply_rx) = oneshot::channel();
let enqueued =
tokio::time::timeout(OSV_ENQUEUE_TIMEOUT, self.tx.send((key, reply_tx))).await;
match enqueued {
Ok(Ok(())) => tokio::time::timeout(self.request_timeout, reply_rx)
.await
.ok()
.and_then(Result::ok)
.unwrap_or(false),
Ok(Err(_)) | Err(_) => 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;
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: &[],
}
}
fn client_with_capacity(capacity: usize, cache_ttl: StdDuration) -> OsvClient {
let (tx, _rx) = mpsc::channel(capacity);
OsvClient {
tx,
cache: OsvCache::new(),
cache_ttl,
request_timeout: StdDuration::from_millis(500),
mode: OsvMode::Enforce,
}
}
struct FixedOsv {
answer: bool,
calls: AtomicUsize,
allowed: usize,
}
impl FixedOsv {
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
async fn evaluate_with_fake(
fake: &FixedOsv,
snapshot: Option<&BlocklistSnapshot>,
now: i64,
cooldown: u64,
candidate: &Candidate<'_>,
) -> Decision {
let blind = policy::evaluate(snapshot, now, cooldown, candidate, false);
if !matches!(blind, Decision::Allow) {
return blind;
}
let calls = fake.calls.fetch_add(1, Ordering::SeqCst);
assert!(
calls < fake.allowed,
"the fake OSV client was asked more than expected"
);
policy::evaluate(snapshot, now, cooldown, candidate, fake.answer)
}
#[tokio::test]
async fn evaluate_skips_osv_when_producer_already_denies() {
let now = 10_000_000;
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":[]}"#,
now,
)
.expect("a valid snapshot");
let fake = FixedOsv {
answer: true,
calls: AtomicUsize::new(0),
allowed: 0,
};
let decision = evaluate_with_fake(
&fake,
Some(&blocked),
now,
0,
&candidate("left-pad", "1.0.0"),
)
.await;
assert_eq!(
decision,
Decision::Deny(crate::policy::DenyReason::BlockedPackage)
);
assert_eq!(
fake.calls(),
0,
"OSV must not be asked about an already-denied candidate"
);
}
#[tokio::test]
async fn evaluate_calls_osv_only_when_producer_would_allow() {
let now = 10_000_000;
let clear = 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");
let fake = FixedOsv {
answer: true,
calls: AtomicUsize::new(0),
allowed: 1,
};
let decision =
evaluate_with_fake(&fake, Some(&clear), now, 0, &candidate("left-pad", "1.0.0")).await;
assert_eq!(
decision,
Decision::Deny(crate::policy::DenyReason::BlockedByOsv)
);
assert_eq!(
fake.calls(),
1,
"a clean candidate must ask OSV exactly once"
);
}
#[tokio::test]
async fn check_returns_cached_answer_without_a_batcher_round_trip() {
let client = client_with_capacity(1, StdDuration::from_secs(60));
client.cache.put(
(Ecosystem::Npm, "left-pad".to_owned(), "1.0.0".to_owned()),
true,
StdDuration::from_secs(60),
);
let first = client.check(Ecosystem::Npm, "left-pad", "1.0.0").await;
let second = client.check(Ecosystem::Npm, "left-pad", "1.0.0").await;
assert!(first);
assert!(
second,
"the cached answer, not a fresh (and here impossible) round trip"
);
}
#[tokio::test]
async fn check_fails_open_on_full_channel() {
let (tx, _rx) = mpsc::channel(1);
let client = OsvClient {
tx,
cache: OsvCache::new(),
cache_ttl: StdDuration::from_secs(60),
request_timeout: StdDuration::from_millis(500),
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 < StdDuration::from_millis(500),
"the enqueue must fail open at OSV_ENQUEUE_TIMEOUT, not block; took {elapsed:?}"
);
}
async fn hung_server() -> (Url, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
let hold = tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
held.push(stream);
}
});
(url, hold)
}
#[tokio::test]
async fn check_fails_open_on_batcher_timeout() {
let (url, hold) = hung_server().await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
StdDuration::from_millis(50),
OsvMode::Enforce,
shutdown.clone(),
);
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,
"resolve must bound the reply wait to request_timeout (C12c/C14), not the \
batcher's own OSV_BATCH_INTERVAL flush cadence; took {elapsed:?}"
);
shutdown.cancel();
let _ = handle.await;
hold.abort();
}
#[tokio::test]
async fn check_bounds_a_solitary_lookup_to_request_timeout_not_the_batch_interval() {
let (url, hold) = hung_server().await;
let shutdown = CancellationToken::new();
let request_timeout = StdDuration::from_millis(20);
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
request_timeout,
OsvMode::Enforce,
shutdown.clone(),
);
let started = Instant::now();
let matched = client.check(Ecosystem::Npm, "left-pad", "1.0.0").await;
let elapsed = started.elapsed();
assert!(!matched, "an unresolved lookup fails open");
assert!(
elapsed < OSV_BATCH_INTERVAL / 10,
"a solitary lookup must be bounded by request_timeout ({request_timeout:?}), \
not by waiting for a flush the batch never fills toward on its own; took \
{elapsed:?}, OSV_BATCH_INTERVAL is {OSV_BATCH_INTERVAL:?}"
);
shutdown.cancel();
let _ = handle.await;
hold.abort();
}
#[tokio::test]
async fn check_writes_negative_ttl_on_failure() {
let (url, hold) = hung_server().await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
StdDuration::from_millis(50),
OsvMode::Enforce,
shutdown.clone(),
);
let key = (Ecosystem::Npm, "left-pad".to_owned(), "1.0.0".to_owned());
assert!(!client.check(Ecosystem::Npm, "left-pad", "1.0.0").await);
let cached = client
.cache
.get(&key)
.expect("a failure still writes a cache entry");
assert!(!cached);
tokio::time::sleep(OSV_NEGATIVE_TTL + StdDuration::from_millis(10)).await;
assert!(
client.cache.get(&key).is_none(),
"a failure's cache entry must use the short negative TTL, not the 60s positive one"
);
shutdown.cancel();
let _ = handle.await;
hold.abort();
}
#[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);
shutdown.cancel();
handle.await.expect("the one spawned batcher task joins");
}
async fn mode_server(
matched: bool,
) -> (
Url,
wiremock::MockServer,
std::sync::Arc<AtomicUsize>,
) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let count = std::sync::Arc::new(AtomicUsize::new(0));
let counted = std::sync::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(200).set_body_json(serde_json::json!({"results": results}))
})
.mount(&server)
.await;
let url = Url::parse(&format!("{}/v1/querybatch", server.uri())).unwrap();
(url, server, count)
}
async fn panicking_server() -> (Url, wiremock::MockServer) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/querybatch"))
.respond_with(|_: &wiremock::Request| {
panic!("off mode must never ask OSV");
#[allow(unreachable_code)]
ResponseTemplate::new(200)
})
.mount(&server)
.await;
let url = Url::parse(&format!("{}/v1/querybatch", server.uri())).unwrap();
(url, server)
}
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")
}
#[tokio::test]
async fn evaluate_off_mode_never_calls_check() {
let (url, _server) = panicking_server().await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
StdDuration::from_millis(500),
OsvMode::Off,
shutdown.clone(),
);
let now = 10_000_000;
let snapshot = clean_snapshot(now);
let decision = evaluate(&client, Some(&snapshot), now, 0, &candidate("left-pad", "1.0.0"))
.await;
assert_eq!(decision, Decision::Allow, "off mode never asks OSV");
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn evaluate_enforce_mode_denies_on_match() {
let (url, _server, _count) = mode_server(true).await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
OSV_BATCH_INTERVAL + StdDuration::from_secs(1),
OsvMode::Enforce,
shutdown.clone(),
);
let now = 10_000_000;
let snapshot = clean_snapshot(now);
let decision = evaluate(&client, Some(&snapshot), now, 0, &candidate("left-pad", "1.0.0"))
.await;
assert_eq!(
decision,
Decision::Deny(crate::policy::DenyReason::BlockedByOsv)
);
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn evaluate_diagnostic_mode_allows_on_match() {
let (url, _server, _count) = mode_server(true).await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
OSV_BATCH_INTERVAL + StdDuration::from_secs(1),
OsvMode::Diagnostic,
shutdown.clone(),
);
let now = 10_000_000;
let snapshot = clean_snapshot(now);
let decision = evaluate(&client, Some(&snapshot), now, 0, &candidate("left-pad", "1.0.0"))
.await;
assert_eq!(decision, Decision::Allow, "diagnostic mode never denies");
shutdown.cancel();
let _ = handle.await;
}
#[tokio::test]
async fn evaluate_diagnostic_mode_allows_without_match() {
let (url, _server, _count) = mode_server(false).await;
let shutdown = CancellationToken::new();
let (client, handle) = OsvClient::spawn_with(
Client::new(),
url,
StdDuration::from_secs(60),
OSV_BATCH_INTERVAL + StdDuration::from_secs(1),
OsvMode::Diagnostic,
shutdown.clone(),
);
let now = 10_000_000;
let snapshot = clean_snapshot(now);
let decision = evaluate(&client, Some(&snapshot), now, 0, &candidate("left-pad", "1.0.0"))
.await;
assert_eq!(decision, Decision::Allow);
shutdown.cancel();
let _ = handle.await;
}
struct NoopTransport;
#[async_trait::async_trait]
impl crate::upstream::Transport for NoopTransport {
async fn fetch_metadata(
&self,
_req: crate::upstream::MetadataRequest,
) -> Result<crate::upstream::MetadataResponse, crate::upstream::UpstreamError> {
Err(crate::upstream::UpstreamError::Status(404))
}
async fn open_artifact(
&self,
_req: crate::upstream::ArtifactRequest,
) -> Result<crate::upstream::ArtifactBody, crate::upstream::UpstreamError> {
Err(crate::upstream::UpstreamError::Status(404))
}
}
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(NoopTransport),
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");
}
}