use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use crate::App;
use crate::policy::blocklist::{self, Replacement};
use crate::policy::{BlocklistError, BlocklistSnapshot};
pub const BACKSTOP_REREAD: Duration = Duration::from_secs(60);
pub async fn run(app: Arc<App>, shutdown: CancellationToken, mut watcher: Watcher) {
let interval = Duration::from_secs(app.config.blocklist_poll_seconds.get());
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
_ = tokio::time::sleep(interval) => {}
}
poll_once(&app, &mut watcher).await;
}
}
#[derive(Default)]
pub struct Watcher {
seen: Option<FileIdentity>,
last_full_read: Option<Instant>,
}
impl Watcher {
pub fn new() -> Watcher {
Watcher::default()
}
fn retry(&mut self) {
self.seen = None;
}
fn retry_if(&mut self, retry: Retry) {
if retry == Retry::Later {
self.retry();
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Retry {
Later,
NotUntilItChanges,
}
pub async fn poll_once(app: &App, watcher: &mut Watcher) {
let path = app.config.blocklist_file.as_path();
let now_monotonic = app.clock.now_monotonic();
let identity = match FileIdentity::of(path) {
Ok(identity) => Some(identity),
Err(err) => {
tracing::error!(
path = %path.display(),
error = %err,
"the blocklist file cannot be examined; the last accepted snapshot stays in force"
);
None
}
};
let unchanged = identity.is_some() && identity == watcher.seen;
let backstop_due = watcher
.last_full_read
.is_none_or(|last| now_monotonic.saturating_duration_since(last) >= BACKSTOP_REREAD);
if unchanged && !backstop_due {
return;
}
let bytes = match read_capped(path, app.config.max_blocklist_bytes.get()) {
Ok(Some(bytes)) => bytes,
Ok(None) => {
tracing::error!(
path = %path.display(),
limit = app.config.max_blocklist_bytes.get(),
"the blocklist file is larger than max_blocklist_bytes; the last accepted \
snapshot stays in force"
);
return;
}
Err(err) => {
tracing::error!(
path = %path.display(),
error = %err,
"the blocklist file cannot be read; the last accepted snapshot stays in force"
);
return;
}
};
watcher.seen = identity;
watcher.last_full_read = Some(now_monotonic);
let accepted = app.blocklist();
if accepted
.as_ref()
.is_some_and(|snapshot| snapshot.raw.as_ref() == bytes.as_slice())
{
return;
}
let now = app.clock.now_utc_micros();
let candidate = match BlocklistSnapshot::parse_and_validate(&bytes, now) {
Ok(candidate) => candidate,
Err(err) => {
watcher.retry_if(reject(path, &err));
return;
}
};
match blocklist::check_replacement(accepted.as_deref(), &candidate) {
Ok(Replacement::Accept) => {}
Ok(Replacement::NoOp) => return,
Err(err) => {
watcher.retry_if(reject(path, &err));
return;
}
}
let candidate = Arc::new(candidate);
let revision = candidate.revision;
let entries = candidate.entry_count();
if let Err(err) = app.store().commit_blocklist(Arc::clone(&candidate)).await {
tracing::error!(
path = %path.display(),
revision,
error = %err,
"the accepted blocklist could not be persisted, so it is not published; the \
previous snapshot stays in force"
);
watcher.retry();
return;
}
app.publish_blocklist(candidate);
tracing::info!(
path = %path.display(),
revision,
entries,
"blocklist accepted, committed and published"
);
}
const RETRYABLE_WINDOW_REASON: &str = "generated_at is in the future";
fn reject(path: &Path, err: &BlocklistError) -> Retry {
let retry = match err {
BlocklistError::Window { reason } if *reason == RETRYABLE_WINDOW_REASON => Retry::Later,
_ => Retry::NotUntilItChanges,
};
tracing::error!(
path = %path.display(),
error = %err,
retry = ?retry,
"the blocklist candidate was rejected; the last accepted snapshot stays in force"
);
retry
}
fn read_capped(path: &Path, max_bytes: u64) -> io::Result<Option<Vec<u8>>> {
let file = File::open(path)?;
let mut bytes = Vec::new();
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > max_bytes {
return Ok(None);
}
Ok(Some(bytes))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct FileIdentity {
device: u64,
inode: u64,
modified_nanos: i128,
len: u64,
}
impl FileIdentity {
#[cfg(unix)]
fn of(path: &Path) -> io::Result<FileIdentity> {
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::metadata(path)?;
Ok(FileIdentity {
device: metadata.dev(),
inode: metadata.ino(),
modified_nanos: i128::from(metadata.mtime()) * 1_000_000_000
+ i128::from(metadata.mtime_nsec()),
len: metadata.len(),
})
}
#[cfg(not(unix))]
fn of(path: &Path) -> io::Result<FileIdentity> {
let metadata = std::fs::metadata(path)?;
let modified_nanos = metadata
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_nanos() as i128)
.unwrap_or(0);
Ok(FileIdentity {
device: 0,
inode: 0,
modified_nanos,
len: metadata.len(),
})
}
}