use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};
use axum::body::HttpBody;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::header;
use axum::middleware::Next;
use axum::response::Response;
use crate::App;
use crate::clock::Clock;
use crate::delivery::{self, Decision, Record, Sinks, Summary};
use crate::http::error::ApiError;
use crate::policy::Ecosystem;
const MAX_LOGGED_TARGET: usize = 256;
static SUMMARY_MILLIS: AtomicU64 = AtomicU64::new(60_000);
tokio::task_local! {
static CONTEXT: Arc<RequestContext>;
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CacheStatus {
Unattempted,
Hit,
Miss,
}
impl CacheStatus {
fn as_str(self) -> &'static str {
match self {
CacheStatus::Unattempted => "none",
CacheStatus::Hit => "hit",
CacheStatus::Miss => "miss",
}
}
fn from_code(code: u8) -> CacheStatus {
match code {
1 => CacheStatus::Hit,
2 => CacheStatus::Miss,
_ => CacheStatus::Unattempted,
}
}
fn code(self) -> u8 {
match self {
CacheStatus::Unattempted => 0,
CacheStatus::Hit => 1,
CacheStatus::Miss => 2,
}
}
}
struct RequestContext {
id: String,
cache: AtomicU8,
ecosystem: AtomicU8,
osv_diagnostic_match: AtomicBool,
}
const ECOSYSTEM_NPM: u8 = 1;
const ECOSYSTEM_PYPI: u8 = 2;
pub fn request_id() -> String {
CONTEXT
.try_with(|context| context.id.clone())
.unwrap_or_else(|_| next_id())
}
pub fn record_cache(status: CacheStatus) {
let _ = CONTEXT.try_with(|context| context.cache.store(status.code(), Ordering::Relaxed));
}
pub fn record_ecosystem(ecosystem: Ecosystem) {
let code = match ecosystem {
Ecosystem::Npm => ECOSYSTEM_NPM,
Ecosystem::PyPi => ECOSYSTEM_PYPI,
};
let _ = CONTEXT.try_with(|context| context.ecosystem.store(code, Ordering::Relaxed));
}
pub(crate) const OSV_DIAGNOSTIC_REASON: &str =
"the request would be blocked by a known OSV malicious-package advisory (diagnostic mode: not enforced)";
pub fn record_osv_diagnostic_match() {
let _ = CONTEXT.try_with(|context| context.osv_diagnostic_match.store(true, Ordering::Relaxed));
}
pub async fn decide(State(app): State<Arc<App>>, request: Request, next: Next) -> Response {
let started = Instant::now();
let target = Target::of(request.uri().path());
let method = request.method().clone();
let consumer = app
.config
.log_consumer_identification
.then(|| {
request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(peer)| peer.ip())
})
.flatten();
let context = Arc::new(RequestContext {
id: next_id(),
cache: AtomicU8::new(CacheStatus::Unattempted.code()),
ecosystem: AtomicU8::new(0),
osv_diagnostic_match: AtomicBool::new(false),
});
let response = CONTEXT.scope(Arc::clone(&context), next.run(request)).await;
let status = response.status();
let bytes = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.or_else(|| response.body().size_hint().exact())
.unwrap_or(0);
let elapsed = started.elapsed();
let error = response.extensions().get::<ApiError>().copied();
let (result, reason) = match error {
Some(error) => (error.error_code(), error.reason()),
None => {
if context.osv_diagnostic_match.load(Ordering::Relaxed) {
("ALLOWED", OSV_DIAGNOSTIC_REASON.to_owned())
} else {
("ALLOWED", "the request was served".to_owned())
}
}
};
let ecosystem = match context.ecosystem.load(Ordering::Relaxed) {
ECOSYSTEM_NPM => Ecosystem::Npm.as_tag(),
ECOSYSTEM_PYPI => Ecosystem::PyPi.as_tag(),
_ => target.ecosystem,
};
let decision = build_decision(
delivery::rfc3339(app.clock.now_utc_micros()),
context.id.clone(),
method.as_str(),
ecosystem,
target,
reason,
status.as_u16(),
result,
app.blocklist_revision().unwrap_or(0),
CacheStatus::from_code(context.cache.load(Ordering::Relaxed)).as_str(),
elapsed.as_micros() as u64,
bytes,
consumer,
);
tracing::info!(
request_id = %decision.request_id,
method = %decision.method,
ecosystem = decision.ecosystem,
package = decision.package.as_str(),
version = decision.version.as_str(),
status = decision.status,
result = decision.result,
reason = %decision.reason,
blocklist_revision = decision.blocklist_revision,
cache = decision.cache,
duration_micros = decision.duration_micros,
bytes = decision.bytes,
consumer = decision.consumer.map(tracing::field::display),
"request decided"
);
app.delivery.offer(Record::RequestDecided(decision));
summarise(
elapsed,
bytes,
error.is_some(),
&app.delivery,
app.clock.as_ref(),
);
response
}
#[allow(clippy::too_many_arguments)]
fn build_decision(
timestamp: String,
request_id: String,
method: &str,
ecosystem: &'static str,
target: Target,
reason: String,
status: u16,
result: &'static str,
blocklist_revision: u64,
cache: &'static str,
duration_micros: u64,
bytes: u64,
consumer: Option<IpAddr>,
) -> Decision {
Decision {
timestamp,
request_id,
method: loggable(Some(method)).unwrap_or_default(),
ecosystem,
package: target.package.unwrap_or_default(),
version: target.version.unwrap_or_default(),
status,
result,
reason,
blocklist_revision,
cache,
duration_micros,
bytes,
consumer,
}
}
struct Target {
ecosystem: &'static str,
package: Option<String>,
version: Option<String>,
}
impl Target {
fn of(path: &str) -> Target {
let mut segments = path.split('/').skip(1);
let root = match segments.next() {
Some("npm") => "npm",
Some("pypi") => "pypi",
Some("health") => {
return Target {
ecosystem: "health",
package: None,
version: None,
};
}
_ => {
return Target {
ecosystem: "",
package: None,
version: None,
};
}
};
let first = segments.next();
let second = segments.next();
let third = segments.next();
let fourth = segments.next();
match (root, first, second, third, fourth) {
(_, Some("artifacts"), Some(id), Some(filename), None) => Target {
ecosystem: root,
package: loggable(Some(id)),
version: loggable(Some(filename)),
},
("pypi", _, project, ..) => Target {
ecosystem: root,
package: loggable(project),
version: None,
},
(_, package, version, ..) => Target {
ecosystem: root,
package: loggable(package),
version: loggable(version),
},
}
}
}
fn loggable(segment: Option<&str>) -> Option<String> {
let segment = segment.filter(|segment| !segment.is_empty())?;
let bounded: String = segment.chars().take(MAX_LOGGED_TARGET).collect();
Some(format!("{bounded:?}"))
}
fn next_id() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(1);
format!("req-{:016x}", COUNTER.fetch_add(1, Ordering::Relaxed))
}
struct Counters {
requests: u64,
errors: u64,
bytes: u64,
micros: u64,
opened: Instant,
}
static COUNTERS: LazyLock<Mutex<Counters>> = LazyLock::new(|| {
Mutex::new(Counters {
requests: 0,
errors: 0,
bytes: 0,
micros: 0,
opened: Instant::now(),
})
});
fn summarise(elapsed: Duration, bytes: u64, was_error: bool, sinks: &Sinks, clock: &dyn Clock) {
let window = Duration::from_millis(SUMMARY_MILLIS.load(Ordering::Relaxed));
let mut counters = COUNTERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
counters.requests += 1;
counters.errors += u64::from(was_error);
counters.bytes += bytes;
counters.micros += elapsed.as_micros() as u64;
let open_for = counters.opened.elapsed();
if open_for < window {
return;
}
let summary = close_window(&mut counters, open_for, sinks, clock);
drop(counters);
emit(summary, sinks);
}
pub(crate) fn flush_summary(sinks: &Sinks, clock: &dyn Clock) {
let mut counters = COUNTERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let open_for = counters.opened.elapsed();
let summary = close_window(&mut counters, open_for, sinks, clock);
drop(counters);
emit(summary, sinks);
}
pub(crate) fn flush_drop_tail(sinks: &Sinks) {
let drops = sinks.drops();
if drops.file != 0 || drops.siem != 0 {
tracing::warn!(
dropped_file = drops.file,
dropped_siem = drops.siem,
"decision records were dropped while delivery was draining and could not be delivered"
);
}
}
fn close_window(
counters: &mut Counters,
open_for: Duration,
sinks: &Sinks,
clock: &dyn Clock,
) -> Summary {
let drops = sinks.drops();
let summary = Summary {
timestamp: delivery::rfc3339(clock.now_utc_micros()),
requests: counters.requests,
errors: counters.errors,
bytes: counters.bytes,
mean_duration_micros: counters.micros / counters.requests.max(1),
window_micros: open_for.as_micros() as u64,
dropped_file: drops.file,
dropped_siem: drops.siem,
};
counters.requests = 0;
counters.errors = 0;
counters.bytes = 0;
counters.micros = 0;
counters.opened = Instant::now();
summary
}
fn emit(summary: Summary, sinks: &Sinks) {
tracing::info!(
requests = summary.requests,
errors = summary.errors,
bytes = summary.bytes,
mean_duration_micros = summary.mean_duration_micros,
window_micros = summary.window_micros,
dropped_file = summary.dropped_file,
dropped_siem = summary.dropped_siem,
"request summary"
);
sinks.offer(Record::RequestSummary(summary));
}
#[cfg(feature = "test-support")]
pub fn set_summary_window(window: Duration) {
SUMMARY_MILLIS.store(window.as_millis() as u64, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use proptest::strategy::Strategy;
use super::*;
use crate::delivery::{BYTES_PER_RECORD, FIELD_CEILING_BYTES};
fn component(max_len: usize) -> impl Strategy<Value = String> {
proptest::collection::vec(proptest::prelude::any::<char>(), 0..max_len)
.prop_map(String::from_iter)
}
fn decision_for(method: &str, path: &str) -> Decision {
let target = Target::of(path);
build_decision(
delivery::rfc3339(0),
next_id(),
method,
target.ecosystem,
target,
ApiError::PolicyUnavailable.reason(),
503,
"POLICY_UNAVAILABLE",
0,
CacheStatus::Unattempted.as_str(),
0,
0,
None,
)
}
fn heap_footprint(decision: &Decision) -> u64 {
let strings = [
&decision.timestamp,
&decision.request_id,
&decision.method,
&decision.package,
&decision.version,
&decision.reason,
];
size_of::<Decision>() as u64
+ strings
.iter()
.map(|field| field.capacity() as u64)
.sum::<u64>()
}
proptest::proptest! {
#[test]
fn rl16b_a_generated_method_is_bounded(method in component(1024)) {
let decision = decision_for(&method, "/npm/left-pad");
if method.is_empty() {
proptest::prop_assert_eq!(decision.method.as_str(), "");
} else {
proptest::prop_assert!(
decision.method.starts_with('"') && decision.method.ends_with('"'),
"a method reaches the record quoted, as a package does: {:?}",
decision.method
);
proptest::prop_assert!(
!decision.method.contains('\n') && !decision.method.contains('\r'),
"a newline in a method must be escaped, not written: {:?}",
decision.method
);
}
if !method.contains('/') {
let sibling = decision_for(&method, &format!("/npm/{method}"));
proptest::prop_assert_eq!(&sibling.method, &sibling.package);
}
proptest::prop_assert!(
decision.method.capacity() as u64 <= FIELD_CEILING_BYTES,
"the method field holds {} bytes against a {FIELD_CEILING_BYTES}-byte ceiling",
decision.method.capacity()
);
}
#[test]
fn rl22_a_built_record_fits_the_per_record_ceiling(
method in component(20_000),
package in component(20_000),
version in component(20_000),
) {
let decision = decision_for(&method, &format!("/npm/{package}/{version}"));
let footprint = heap_footprint(&decision);
proptest::prop_assert!(
footprint <= BYTES_PER_RECORD,
"a record built from generated input occupies {footprint} bytes, past the \
{BYTES_PER_RECORD} an operator's queue budget is divided by"
);
}
}
#[test]
fn a_hostile_route_component_cannot_forge_a_log_line() {
let target = Target::of("/npm/left-pad\r\nINFO forged/1.0.0");
assert_eq!(target.ecosystem, "npm");
let package = target.package.expect("a package component");
assert!(
!package.contains('\n') && !package.contains('\r'),
"a newline in a route component must be escaped, not written: {package}"
);
}
#[test]
fn an_unrecognised_target_contributes_nothing() {
let target = Target::of("/../../etc/shadow");
assert_eq!(target.ecosystem, "");
assert!(target.package.is_none());
assert!(target.version.is_none());
}
#[test]
fn record_osv_diagnostic_match_outside_a_request_is_a_no_op() {
record_osv_diagnostic_match();
}
#[test]
fn a_long_component_is_bounded() {
let long = "a".repeat(4096);
let target = Target::of(&format!("/npm/{long}"));
let package = target.package.expect("a package component");
assert!(
package.len() <= MAX_LOGGED_TARGET + 2,
"a route component is bounded before it reaches a log line, got {} characters",
package.len()
);
}
}