use chrono::Utc;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use tracing::{error, info, warn};
use axum::http::{header, HeaderName, StatusCode};
use axum::response::{IntoResponse, Response};
const PRUNE_TTL_SECS: i64 = 90 * 24 * 3600;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum QuarantineMode {
#[default]
Off,
Observe,
Enforce,
}
impl std::fmt::Display for QuarantineMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Off => write!(f, "off"),
Self::Observe => write!(f, "observe"),
Self::Enforce => write!(f, "enforce"),
}
}
}
impl std::str::FromStr for QuarantineMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"off" => Ok(Self::Off),
"observe" => Ok(Self::Observe),
"enforce" => Ok(Self::Enforce),
other => Err(format!(
"unknown quarantine mode {:?} — valid values: off, observe, enforce",
other
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestEntry {
pub registry: String,
pub digest: String,
pub first_seen: i64,
pub upstream: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum QuarantineStatus {
New,
Pending { remaining_secs: i64 },
Mature,
}
impl QuarantineStatus {
pub fn header_value(&self) -> &'static str {
match self {
Self::New => "new",
Self::Pending { .. } => "pending",
Self::Mature => "mature",
}
}
}
pub struct DigestStore {
entries: RwLock<HashMap<String, DigestEntry>>,
path: PathBuf,
}
impl DigestStore {
pub fn load(storage_path: &str) -> Self {
let path = PathBuf::from(storage_path).join("quarantine.jsonl");
let mut entries = HashMap::new();
if path.exists() {
match File::open(&path) {
Ok(file) => {
let reader = BufReader::new(file);
let now = Utc::now().timestamp();
let cutoff = now - PRUNE_TTL_SECS;
let mut skipped = 0u32;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
warn!(error = %e, "Skipping unreadable quarantine line");
skipped += 1;
continue;
}
};
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<DigestEntry>(&line) {
Ok(entry) => {
if entry.first_seen >= cutoff {
let key = format!("{}:{}", entry.registry, entry.digest);
entries.entry(key).or_insert(entry);
}
}
Err(e) => {
warn!(error = %e, "Skipping unparsable quarantine entry");
skipped += 1;
}
}
}
info!(
path = %path.display(),
entries = entries.len(),
skipped = skipped,
"Quarantine store loaded"
);
}
Err(e) => {
error!(
path = %path.display(),
error = %e,
"Failed to open quarantine file, starting empty (fail-open)"
);
}
}
atomic_rewrite(&path, &entries);
}
Self {
entries: RwLock::new(entries),
path,
}
}
pub fn empty(storage_path: &str) -> Self {
let path = PathBuf::from(storage_path).join("quarantine.jsonl");
Self {
entries: RwLock::new(HashMap::new()),
path,
}
}
pub fn record(
&self,
registry: &str,
digest: &str,
upstream: &str,
first_seen: Option<i64>,
) -> DigestEntry {
let key = format!("{}:{}", registry, digest);
{
let entries = self.entries.read();
if let Some(entry) = entries.get(&key) {
return entry.clone();
}
}
let mut entries = self.entries.write();
if let Some(entry) = entries.get(&key) {
return entry.clone();
}
let entry = DigestEntry {
registry: registry.to_string(),
digest: digest.to_string(),
first_seen: first_seen.unwrap_or_else(|| Utc::now().timestamp()),
upstream: upstream.to_string(),
};
entries.insert(key, entry.clone());
append_jsonl(&self.path, &entry);
entry
}
#[must_use = "ignoring quarantine status may serve blocked artifacts"]
pub fn check(&self, registry: &str, digest: &str, quarantine_secs: i64) -> QuarantineStatus {
let key = format!("{}:{}", registry, digest);
let entries = self.entries.read();
match entries.get(&key) {
None => QuarantineStatus::New,
Some(entry) => {
let age = Utc::now().timestamp() - entry.first_seen;
if age >= quarantine_secs {
QuarantineStatus::Mature
} else {
QuarantineStatus::Pending {
remaining_secs: quarantine_secs - age,
}
}
}
}
}
pub fn len(&self) -> usize {
self.entries.read().len()
}
pub fn is_empty(&self) -> bool {
self.entries.read().is_empty()
}
}
pub fn resolve_global(mode: Option<&QuarantineMode>, ttl: Option<&str>) -> (QuarantineMode, i64) {
let mode = mode.cloned().unwrap_or(QuarantineMode::Off);
if matches!(mode, QuarantineMode::Off) {
return (QuarantineMode::Off, 0);
}
let secs = crate::curation::parse_duration(ttl.unwrap_or("14d")).unwrap_or(14 * 86400);
(mode, secs)
}
#[must_use = "the returned response blocks a quarantined artifact; dropping it serves the artifact"]
pub fn proxy_gate(
store: &DigestStore,
registry: &str,
bytes: &[u8],
mode: &QuarantineMode,
quarantine_secs: i64,
upstream: &str,
) -> Option<Response> {
proxy_gate_dated(
store,
registry,
bytes,
mode,
quarantine_secs,
upstream,
None,
)
}
#[must_use = "the returned response blocks a quarantined artifact; dropping it serves the artifact"]
pub fn proxy_gate_dated(
store: &DigestStore,
registry: &str,
bytes: &[u8],
mode: &QuarantineMode,
quarantine_secs: i64,
upstream: &str,
trusted_first_seen: Option<i64>,
) -> Option<Response> {
if matches!(mode, QuarantineMode::Off) {
return None;
}
use sha2::Digest;
let digest = format!("sha256:{}", hex::encode(sha2::Sha256::digest(bytes)));
store.record(registry, &digest, upstream, trusted_first_seen);
let status = store.check(registry, &digest, quarantine_secs);
if matches!(status, QuarantineStatus::Mature) {
return None;
}
let outcome = if matches!(mode, QuarantineMode::Enforce) {
"blocked"
} else {
"observed"
};
crate::metrics::QUARANTINE_HOLDS_TOTAL
.with_label_values(&[registry, outcome])
.inc();
warn!(
registry = %registry,
digest = %digest,
status = %status.header_value(),
mode = %mode,
quarantine_ttl_secs = quarantine_secs,
"quarantine: proxy artifact held by registry policy"
);
if matches!(mode, QuarantineMode::Enforce) {
Some(quarantine_forbidden(
registry,
&digest,
&status,
quarantine_secs,
))
} else {
None
}
}
fn quarantine_forbidden(
registry: &str,
digest: &str,
status: &QuarantineStatus,
quarantine_secs: i64,
) -> Response {
let remaining = match status {
QuarantineStatus::New => quarantine_secs,
QuarantineStatus::Pending { remaining_secs } => *remaining_secs,
QuarantineStatus::Mature => 0,
};
let quarantine_until = Utc::now().timestamp() + remaining;
let body = serde_json::json!({
"error": "quarantine",
"message": format!(
"held by quarantine policy on registry '{}': artifact must age past the quarantine window before it is served",
registry
),
"detail": {
"registry": registry,
"digest": digest,
"policy": {
"control": "quarantine",
"quarantine_ttl_secs": quarantine_secs,
},
"quarantine_until": quarantine_until,
"remaining_secs": remaining,
}
});
(
StatusCode::FORBIDDEN,
[
(
HeaderName::from_static("x-nora-quarantine"),
status.header_value(),
),
(header::CONTENT_TYPE, "application/json"),
],
body.to_string(),
)
.into_response()
}
fn append_jsonl(path: &Path, entry: &DigestEntry) {
let path = path.to_path_buf();
let json = match serde_json::to_string(entry) {
Ok(j) => j,
Err(_) => return,
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
tokio::task::spawn_blocking(move || {
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(mut file) => {
let _ = writeln!(file, "{}", json);
let _ = file.flush();
}
Err(e) => {
error!(
path = %path.display(),
error = %e,
"Failed to append quarantine entry (fail-open)"
);
}
}
});
}
fn atomic_rewrite(path: &Path, entries: &HashMap<String, DigestEntry>) {
if entries.is_empty() {
let _ = fs::remove_file(path);
return;
}
let tmp_path = path.with_extension("jsonl.tmp");
match File::create(&tmp_path) {
Ok(mut file) => {
for entry in entries.values() {
if let Ok(json) = serde_json::to_string(entry) {
let _ = writeln!(file, "{}", json);
}
}
if file.flush().is_ok() && file.sync_all().is_ok() {
if let Err(e) = fs::rename(&tmp_path, path) {
error!(error = %e, "Failed to rename quarantine temp file");
let _ = fs::remove_file(&tmp_path);
}
} else {
let _ = fs::remove_file(&tmp_path);
}
}
Err(e) => {
error!(error = %e, "Failed to create quarantine temp file");
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_quarantine_mode_from_str() {
assert_eq!(
"off".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Off
);
assert_eq!(
"observe".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Observe
);
assert_eq!(
"enforce".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Enforce
);
assert_eq!(
"OBSERVE".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Observe
);
assert_eq!(
"ENFORCE".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Enforce
);
assert_eq!(
"Off".parse::<QuarantineMode>().unwrap(),
QuarantineMode::Off
);
}
#[test]
fn test_quarantine_mode_rejects_invalid() {
assert!("anything".parse::<QuarantineMode>().is_err());
assert!("eforce".parse::<QuarantineMode>().is_err());
assert!("enabled".parse::<QuarantineMode>().is_err());
assert!("on".parse::<QuarantineMode>().is_err());
assert!("".parse::<QuarantineMode>().is_err());
let err = "typo".parse::<QuarantineMode>().unwrap_err();
assert!(err.contains("off"), "error should list valid values: {err}");
assert!(
err.contains("observe"),
"error should list valid values: {err}"
);
assert!(
err.contains("enforce"),
"error should list valid values: {err}"
);
}
#[test]
fn test_quarantine_mode_display_roundtrip() {
for mode in [
QuarantineMode::Off,
QuarantineMode::Observe,
QuarantineMode::Enforce,
] {
let s = mode.to_string();
let parsed: QuarantineMode = s.parse().unwrap();
assert_eq!(mode, parsed);
}
}
#[test]
fn test_quarantine_mode_default_is_off() {
assert_eq!(QuarantineMode::default(), QuarantineMode::Off);
}
#[test]
fn test_check_unknown_digest_is_new() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
let status = store.check("docker", "sha256:abc123", 86400);
assert_eq!(status, QuarantineStatus::New);
}
#[tokio::test]
async fn test_record_then_check_pending() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
store.record("docker", "sha256:abc123", "registry-1.docker.io", None);
let status = store.check("docker", "sha256:abc123", 86400);
match status {
QuarantineStatus::Pending { remaining_secs } => {
assert!(remaining_secs > 0);
assert!(remaining_secs <= 86400);
}
other => panic!("Expected Pending, got {:?}", other),
}
}
#[tokio::test]
async fn test_record_then_check_mature_zero_ttl() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
store.record("docker", "sha256:abc123", "registry-1.docker.io", None);
let status = store.check("docker", "sha256:abc123", 0);
assert_eq!(status, QuarantineStatus::Mature);
}
#[tokio::test]
async fn test_unrecorded_digest_is_new() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
let status = store.check("docker", "sha256:never_seen", 86400);
assert_eq!(status, QuarantineStatus::New);
}
#[tokio::test]
async fn test_record_idempotent() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
let entry1 = store.record("docker", "sha256:abc", "upstream1", None);
let entry2 = store.record("docker", "sha256:abc", "upstream2", None);
assert_eq!(entry1.first_seen, entry2.first_seen);
assert_eq!(entry1.upstream, entry2.upstream);
assert_eq!(store.len(), 1);
}
#[tokio::test]
async fn test_registry_isolation() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
store.record("docker", "sha256:abc", "docker-upstream", None);
store.record("npm", "sha256:abc", "npmjs.org", None);
assert_eq!(store.len(), 2);
}
#[tokio::test]
async fn test_trusted_first_seen_matures_old_release() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
let two_years_ago = Utc::now().timestamp() - 2 * 365 * 86400;
store.record(
"pypi",
"sha256:old",
"files.pythonhosted.org",
Some(two_years_ago),
);
assert_eq!(
store.check("pypi", "sha256:old", 14 * 86400),
QuarantineStatus::Mature
);
store.record(
"pypi",
"sha256:fresh",
"files.pythonhosted.org",
Some(Utc::now().timestamp()),
);
assert!(matches!(
store.check("pypi", "sha256:fresh", 14 * 86400),
QuarantineStatus::Pending { .. }
));
assert_eq!(store.len(), 2);
}
#[test]
fn test_persistence_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_str().unwrap();
let jsonl_path = tmp.path().join("quarantine.jsonl");
let now = Utc::now().timestamp();
let entries = [
DigestEntry {
registry: "docker".into(),
digest: "sha256:aaa".into(),
first_seen: now,
upstream: "upstream1".into(),
},
DigestEntry {
registry: "docker".into(),
digest: "sha256:bbb".into(),
first_seen: now,
upstream: "upstream2".into(),
},
];
{
let mut file = std::fs::File::create(&jsonl_path).unwrap();
for e in &entries {
writeln!(file, "{}", serde_json::to_string(e).unwrap()).unwrap();
}
file.flush().unwrap();
}
let store = DigestStore::load(path);
assert_eq!(store.len(), 2);
let status = store.check("docker", "sha256:aaa", 86400);
assert!(matches!(status, QuarantineStatus::Pending { .. }));
}
#[test]
fn test_load_empty_dir() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::load(tmp.path().to_str().unwrap());
assert_eq!(store.len(), 0);
}
#[test]
fn test_load_corrupt_file_fail_open() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("quarantine.jsonl");
let now = Utc::now().timestamp();
let valid = serde_json::json!({
"registry": "docker",
"digest": "sha256:good",
"first_seen": now,
"upstream": "up"
});
std::fs::write(&path, format!("not json\n{}\ngarbage\n", valid)).unwrap();
let store = DigestStore::load(tmp.path().to_str().unwrap());
assert_eq!(store.len(), 1);
}
#[test]
fn test_prune_stale_entries() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("quarantine.jsonl");
let now = Utc::now().timestamp();
let old_ts = now - (91 * 24 * 3600); let fresh_ts = now - (10 * 24 * 3600);
let old = serde_json::json!({
"registry": "docker", "digest": "sha256:old",
"first_seen": old_ts, "upstream": "up"
});
let fresh = serde_json::json!({
"registry": "docker", "digest": "sha256:fresh",
"first_seen": fresh_ts, "upstream": "up"
});
std::fs::write(&path, format!("{}\n{}\n", old, fresh)).unwrap();
let store = DigestStore::load(tmp.path().to_str().unwrap());
assert_eq!(store.len(), 1);
assert!(matches!(
store.check("docker", "sha256:fresh", 86400),
QuarantineStatus::Mature
));
assert_eq!(
store.check("docker", "sha256:old", 86400),
QuarantineStatus::New
);
}
#[test]
fn test_quarantine_status_header_values() {
assert_eq!(QuarantineStatus::New.header_value(), "new");
assert_eq!(
QuarantineStatus::Pending {
remaining_secs: 100
}
.header_value(),
"pending"
);
assert_eq!(QuarantineStatus::Mature.header_value(), "mature");
}
#[test]
fn test_atomic_rewrite_compacts_duplicates() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("quarantine.jsonl");
let now = Utc::now().timestamp();
let entry = serde_json::json!({
"registry": "docker", "digest": "sha256:dup",
"first_seen": now, "upstream": "up"
});
std::fs::write(&path, format!("{}\n{}\n{}\n", entry, entry, entry)).unwrap();
let store = DigestStore::load(tmp.path().to_str().unwrap());
assert_eq!(store.len(), 1);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.lines().count(), 1);
}
#[test]
fn test_empty_store_is_empty() {
let tmp = TempDir::new().unwrap();
let store = DigestStore::empty(tmp.path().to_str().unwrap());
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn test_digest_entry_serialization() {
let entry = DigestEntry {
registry: "docker".to_string(),
digest: "sha256:abc".to_string(),
first_seen: 1700000000,
upstream: "registry-1.docker.io".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"registry\":\"docker\""));
assert!(json.contains("\"first_seen\":1700000000"));
let parsed: DigestEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.digest, "sha256:abc");
}
}