use std::{
collections::{HashMap, VecDeque},
fs::OpenOptions,
io::Write,
path::PathBuf,
};
use anyhow::{anyhow, Context, Result};
use linprov_common::{fnv_hash, MAX_RULES};
use crate::{
allowlist::{RuleSpec, Rules},
config::restrict_perms,
encoding::escape,
};
const MAX_BLOCKS: usize = 512;
#[derive(Clone, Debug)]
pub struct BlockEvent {
pub kind: &'static str,
pub token: String,
pub target: String,
pub creator: String,
}
impl BlockEvent {
pub fn to_wire(&self) -> String {
format!(
"BLOCK\t{}\t{}\t{}\t{}",
self.token,
self.kind,
escape(&self.target),
escape(&self.creator)
)
}
}
#[derive(Default)]
pub struct BlocksTable {
map: HashMap<String, String>,
order: VecDeque<String>,
}
impl BlocksTable {
pub fn record(&mut self, rule_line: String) -> String {
let token = format!("{:08x}", fnv_hash(&rule_line) as u32);
if !self.map.contains_key(&token) {
if self.order.len() >= MAX_BLOCKS {
if let Some(old) = self.order.pop_front() {
self.map.remove(&old);
}
}
self.order.push_back(token.clone());
self.map.insert(token.clone(), rule_line);
}
token
}
fn rule_for(&self, token: &str) -> Option<&str> {
self.map.get(token).map(String::as_str)
}
}
pub struct Control {
allowlist_path: Option<PathBuf>,
transient: Vec<RuleSpec>,
pub blocks: BlocksTable,
}
impl Control {
pub fn new(allowlist_path: Option<PathBuf>) -> Self {
Self {
allowlist_path,
transient: Vec::new(),
blocks: BlocksTable::default(),
}
}
pub fn combined(&self) -> Result<Vec<RuleSpec>> {
let mut rules = match &self.allowlist_path {
Some(p) => Rules::load(p)?.rules,
None => Vec::new(),
};
rules.extend(self.transient.iter().cloned());
Ok(rules)
}
pub fn apply(&mut self, token: &str, once: bool) -> Result<String> {
let line = self
.blocks
.rule_for(token)
.ok_or_else(|| {
anyhow!("unknown token `{token}` (expired, or nothing blocked this daemon session)")
})?
.to_string();
RuleSpec::parse(&line).with_context(|| format!("re-parsing candidate rule `{line}`"))?;
if once {
if self.transient.iter().any(|r| r.to_line() == line) {
return Ok(line); }
if self.combined()?.len() >= MAX_RULES {
return Err(anyhow!(
"allowlist is at capacity ({MAX_RULES} rules); `allow --once` can't \
take effect — transient rules seed after the file rules and would \
be dropped. Trim the allowlist (or restart) first."
));
}
self.transient.push(RuleSpec::parse(&line)?);
} else {
self.append_persistent(&line)?;
}
Ok(line)
}
fn append_persistent(&mut self, line: &str) -> Result<()> {
let path = self.allowlist_path.as_ref().ok_or_else(|| {
anyhow!("no allowlist file configured; cannot persist this rule (try --once)")
})?;
if Rules::load(path)?.rules.iter().any(|r| r.to_line() == line) {
return Ok(());
}
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening `{}` to append", path.display()))?;
restrict_perms(path); writeln!(f, "{line}").with_context(|| format!("appending `{line}`"))?;
f.sync_data().ok();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn once_refused_when_allowlist_at_capacity() {
let mut f = tempfile::NamedTempFile::new().unwrap();
for i in 0..MAX_RULES {
writeln!(f, "creator_uid={i}").unwrap();
}
f.flush().unwrap();
let mut control = Control::new(Some(f.path().to_path_buf()));
let token = control.blocks.record("creator_uid=4294967295".to_string());
let err = control.apply(&token, true).unwrap_err();
assert!(format!("{err:#}").contains("at capacity"), "{err:#}");
}
#[test]
fn once_added_when_under_capacity() {
let mut control = Control::new(None); let token = control.blocks.record("creator_uid=1000".to_string());
let rule = control.apply(&token, true).unwrap();
assert_eq!(rule, "creator_uid=1000");
assert_eq!(control.combined().unwrap().len(), 1);
}
}