use std::{
collections::HashSet,
fs::{File, OpenOptions},
io::{BufRead, BufReader, Write},
path::Path,
sync::Mutex,
};
use anyhow::{anyhow, Context, Result};
use clap::ValueEnum;
use linprov_common::{dim, fnv_hash, AllowRule, COMM_LEN, MAX_RULES};
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize, ValueEnum, PartialEq, Eq, Hash)]
#[clap(rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Dim {
TargetFilename,
TargetFolder,
LandingFilename,
LandingFolder,
CreatorProcess,
CreatorComm,
CreatorUid,
ExecutionUid,
}
impl Dim {
pub fn as_key(self) -> &'static str {
match self {
Dim::TargetFilename => "target_filename",
Dim::TargetFolder => "target_folder",
Dim::LandingFilename => "landing_filename",
Dim::LandingFolder => "landing_folder",
Dim::CreatorProcess => "creator_process",
Dim::CreatorComm => "creator_comm",
Dim::CreatorUid => "creator_uid",
Dim::ExecutionUid => "execution_uid",
}
}
fn parse(s: &str) -> Option<Self> {
Some(match s {
"target_filename" => Dim::TargetFilename,
"target_folder" => Dim::TargetFolder,
"landing_filename" => Dim::LandingFilename,
"landing_folder" => Dim::LandingFolder,
"creator_process" => Dim::CreatorProcess,
"creator_comm" => Dim::CreatorComm,
"creator_uid" => Dim::CreatorUid,
"execution_uid" => Dim::ExecutionUid,
_ => return None,
})
}
}
#[derive(Debug, Default, Clone)]
pub struct RuleSpec {
pub flags: u32,
pub target_filename: Option<String>,
pub target_folder: Option<String>,
pub landing_filename: Option<String>,
pub landing_folder: Option<String>,
pub creator_process: Option<String>,
pub creator_comm: Option<String>,
pub creator_uid: Option<u32>,
pub execution_uid: Option<u32>,
}
impl RuleSpec {
pub fn parse(line: &str) -> Result<Self> {
let mut spec = Self::default();
for cond in line.split(';') {
let cond = cond.trim();
if cond.is_empty() {
continue;
}
let (k, v) = cond
.split_once('=')
.ok_or_else(|| anyhow!("condition `{cond}` is missing `=`"))?;
let k = k.trim();
let v = v.trim();
let dim = Dim::parse(k).ok_or_else(|| anyhow!("unknown dimension `{k}`"))?;
spec.set(dim, v)
.with_context(|| format!("condition `{k}={v}`"))?;
}
if spec.flags == 0 {
return Err(anyhow!("rule has no conditions"));
}
Ok(spec)
}
pub fn set(&mut self, d: Dim, value: &str) -> Result<()> {
let bit = match d {
Dim::TargetFilename => dim::TARGET_FILENAME,
Dim::TargetFolder => dim::TARGET_FOLDER,
Dim::LandingFilename => dim::LANDING_FILENAME,
Dim::LandingFolder => dim::LANDING_FOLDER,
Dim::CreatorProcess => dim::CREATOR_PROCESS,
Dim::CreatorComm => dim::CREATOR_COMM,
Dim::CreatorUid => dim::CREATOR_UID,
Dim::ExecutionUid => dim::EXECUTION_UID,
};
if self.flags & bit != 0 {
return Err(anyhow!(
"dim `{}` specified twice in the same rule",
d.as_key()
));
}
self.flags |= bit;
match d {
Dim::TargetFilename => {
self.target_filename = Some(value.to_string());
}
Dim::TargetFolder => {
self.target_folder = Some(normalize_folder(value));
}
Dim::LandingFilename => {
self.landing_filename = Some(value.to_string());
}
Dim::LandingFolder => {
self.landing_folder = Some(normalize_folder(value));
}
Dim::CreatorProcess => {
self.creator_process = Some(value.to_string());
}
Dim::CreatorComm => {
if value.len() >= COMM_LEN {
return Err(anyhow!(
"creator_comm `{value}` is too long ({} bytes; max {})",
value.len(),
COMM_LEN - 1
));
}
self.creator_comm = Some(value.to_string());
}
Dim::CreatorUid => {
let uid: u32 = value
.parse()
.with_context(|| format!("creator_uid `{value}` is not a u32"))?;
self.creator_uid = Some(uid);
}
Dim::ExecutionUid => {
let uid: u32 = value
.parse()
.with_context(|| format!("execution_uid `{value}` is not a u32"))?;
self.execution_uid = Some(uid);
}
}
Ok(())
}
pub fn to_line(&self) -> String {
let mut parts = Vec::new();
for d in DIM_ORDER {
let bit = dim_bit(*d);
if self.flags & bit == 0 {
continue;
}
let v = match d {
Dim::TargetFilename => self.target_filename.clone(),
Dim::TargetFolder => self.target_folder.clone(),
Dim::LandingFilename => self.landing_filename.clone(),
Dim::LandingFolder => self.landing_folder.clone(),
Dim::CreatorProcess => self.creator_process.clone(),
Dim::CreatorComm => self.creator_comm.clone(),
Dim::CreatorUid => self.creator_uid.map(|u| u.to_string()),
Dim::ExecutionUid => self.execution_uid.map(|u| u.to_string()),
};
if let Some(v) = v {
parts.push(format!("{}={v}", d.as_key()));
}
}
parts.join(";")
}
pub fn pack(&self) -> AllowRule {
let mut creator_comm = [0u8; COMM_LEN];
if let Some(c) = &self.creator_comm {
let b = c.as_bytes();
let n = b.len().min(COMM_LEN - 1);
creator_comm[..n].copy_from_slice(&b[..n]);
}
AllowRule {
flags: self.flags,
creator_uid: self.creator_uid.unwrap_or(0),
execution_uid: self.execution_uid.unwrap_or(0),
_pad: 0,
creator_comm,
target_filename_hash: self.target_filename.as_deref().map(fnv_hash).unwrap_or(0),
target_folder_hash: self.target_folder.as_deref().map(fnv_hash).unwrap_or(0),
landing_filename_hash: self.landing_filename.as_deref().map(fnv_hash).unwrap_or(0),
landing_folder_hash: self.landing_folder.as_deref().map(fnv_hash).unwrap_or(0),
creator_process_hash: self.creator_process.as_deref().map(fnv_hash).unwrap_or(0),
}
}
}
const DIM_ORDER: &[Dim] = &[
Dim::TargetFilename,
Dim::TargetFolder,
Dim::LandingFilename,
Dim::LandingFolder,
Dim::CreatorProcess,
Dim::CreatorComm,
Dim::CreatorUid,
Dim::ExecutionUid,
];
fn dim_bit(d: Dim) -> u32 {
match d {
Dim::TargetFilename => dim::TARGET_FILENAME,
Dim::TargetFolder => dim::TARGET_FOLDER,
Dim::LandingFilename => dim::LANDING_FILENAME,
Dim::LandingFolder => dim::LANDING_FOLDER,
Dim::CreatorProcess => dim::CREATOR_PROCESS,
Dim::CreatorComm => dim::CREATOR_COMM,
Dim::CreatorUid => dim::CREATOR_UID,
Dim::ExecutionUid => dim::EXECUTION_UID,
}
}
fn normalize_folder(value: &str) -> String {
if value.ends_with('/') {
value.to_string()
} else {
format!("{value}/")
}
}
#[derive(Debug, Default)]
pub struct Rules {
pub rules: Vec<RuleSpec>,
seen_lines: HashSet<String>,
}
impl Rules {
pub fn load(path: &Path) -> Result<Self> {
let mut rules = Self::default();
let f = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(rules),
Err(e) => return Err(anyhow!("opening allowlist `{}`: {e}", path.display())),
};
for (i, line) in BufReader::new(f).lines().enumerate() {
let line = line.with_context(|| format!("reading line {}", i + 1))?;
let trimmed = line.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
continue;
}
let spec = RuleSpec::parse(trimmed)
.with_context(|| format!("parsing allowlist line {}: `{trimmed}`", i + 1))?;
rules.insert(spec);
}
Ok(rules)
}
pub fn insert(&mut self, spec: RuleSpec) -> bool {
let line = spec.to_line();
if !self.seen_lines.insert(line) {
return false;
}
self.rules.push(spec);
true
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn check_capacity(&self) -> Result<()> {
if self.rules.len() > MAX_RULES {
return Err(anyhow!(
"{} rules exceeds BPF map capacity ({MAX_RULES}). \
Trim the allowlist or bump MAX_RULES.",
self.rules.len()
));
}
Ok(())
}
}
pub struct Soak {
pub dims: Vec<Dim>,
pub seen: Mutex<HashSet<String>>,
pub writer: Mutex<File>,
}
impl Soak {
pub fn open(path: &Path, dims: Vec<Dim>, preload: &Rules) -> Result<Self> {
let writer = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening allowlist `{}` for soak append", path.display()))?;
Ok(Self {
dims,
seen: Mutex::new(preload.seen_lines.clone()),
writer: Mutex::new(writer),
})
}
pub fn record(&self, ctx: &OriginContext<'_>) -> Result<Option<String>> {
let mut spec = RuleSpec::default();
for d in &self.dims {
let val: Option<String> = match d {
Dim::TargetFilename => non_empty(ctx.target_filename),
Dim::TargetFolder => folder_of(ctx.target_filename),
Dim::LandingFilename => ctx.landing_basename.map(str::to_string),
Dim::LandingFolder => ctx.landing_folder.map(str::to_string),
Dim::CreatorProcess => ctx.creator_path.map(str::to_string),
Dim::CreatorComm => non_empty(ctx.creator_comm),
Dim::CreatorUid => Some(ctx.creator_uid.to_string()),
Dim::ExecutionUid => Some(ctx.execution_uid.to_string()),
};
let Some(val) = val else { continue };
if let Err(e) = spec.set(*d, &val) {
log::warn!("soak: skipping invalid {} value `{val}`: {e}", d.as_key());
}
}
if spec.flags == 0 {
return Ok(None);
}
let line = spec.to_line();
{
let mut seen = self.seen.lock().expect("soak seen mutex poisoned");
if !seen.insert(line.clone()) {
return Ok(None);
}
}
let mut w = self.writer.lock().expect("soak writer mutex poisoned");
writeln!(w, "{line}").with_context(|| format!("appending `{line}`"))?;
w.sync_data().ok();
Ok(Some(line))
}
}
fn non_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn folder_of(path: &str) -> Option<String> {
if path.is_empty() {
return None;
}
match path.rsplit_once('/') {
Some((parent, _)) if !parent.is_empty() => Some(format!("{parent}/")),
Some((_, _)) => Some("/".to_string()),
None => None,
}
}
pub struct OriginContext<'a> {
pub target_filename: &'a str,
pub landing_folder: Option<&'a str>,
pub landing_basename: Option<&'a str>,
pub creator_path: Option<&'a str>,
pub creator_comm: &'a str,
pub creator_uid: u32,
pub execution_uid: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_dim() {
let r = RuleSpec::parse("creator_comm=curl").unwrap();
assert_eq!(r.flags, dim::CREATOR_COMM);
assert_eq!(r.creator_comm.as_deref(), Some("curl"));
}
#[test]
fn parse_multi_dim_anded() {
let r = RuleSpec::parse("creator_uid=1000;creator_comm=curl").unwrap();
assert_eq!(r.flags, dim::CREATOR_UID | dim::CREATOR_COMM);
assert_eq!(r.creator_uid, Some(1000));
assert_eq!(r.creator_comm.as_deref(), Some("curl"));
}
#[test]
fn parse_trailing_semicolons_and_whitespace_ok() {
let r = RuleSpec::parse(" creator_uid = 1000 ;; creator_comm = curl ; ").unwrap();
assert_eq!(r.flags, dim::CREATOR_UID | dim::CREATOR_COMM);
}
#[test]
fn parse_rejects_unknown_dim() {
let err = RuleSpec::parse("nope=1").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("unknown dimension"), "{msg}");
}
#[test]
fn parse_rejects_empty_rule() {
let err = RuleSpec::parse(";;").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no conditions"), "{msg}");
}
#[test]
fn parse_rejects_missing_equals() {
let err = RuleSpec::parse("creator_comm").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("missing `=`"), "{msg}");
}
#[test]
fn parse_rejects_duplicate_dim() {
let err = RuleSpec::parse("creator_uid=1;creator_uid=2").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("specified twice"), "{msg}");
}
#[test]
fn parse_rejects_bad_uid() {
let err = RuleSpec::parse("creator_uid=notanumber").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("not a u32"), "{msg}");
}
#[test]
fn target_folder_normalizes_trailing_slash() {
let r = RuleSpec::parse("target_folder=/opt/installed").unwrap();
assert_eq!(r.target_folder.as_deref(), Some("/opt/installed/"));
let r = RuleSpec::parse("target_folder=/opt/installed/").unwrap();
assert_eq!(r.target_folder.as_deref(), Some("/opt/installed/"));
}
#[test]
fn round_trip_canonical_line() {
let r = RuleSpec::parse("creator_comm=curl;target_filename=/x").unwrap();
let line = r.to_line();
assert_eq!(line, "target_filename=/x;creator_comm=curl");
let r2 = RuleSpec::parse(&line).unwrap();
assert_eq!(r2.to_line(), line);
}
#[test]
fn folder_of_immediate_parent() {
assert_eq!(
folder_of("/opt/my-app/bin/foo").as_deref(),
Some("/opt/my-app/bin/")
);
assert_eq!(folder_of("/foo").as_deref(), Some("/"));
let deep = format!("/{}/x", "a".repeat(5000));
assert_eq!(
folder_of(&deep).as_deref(),
Some(&*format!("/{}/", "a".repeat(5000)))
);
}
#[test]
fn long_path_rule_parses_without_length_limit() {
let long = format!("/opt/{}/bin/", "seg/".repeat(2000));
let r = RuleSpec::parse(&format!("target_folder={long}")).unwrap();
assert_eq!(r.flags, dim::TARGET_FOLDER);
}
#[test]
fn pack_sets_only_required_hashes() {
let r = RuleSpec::parse("creator_uid=1000").unwrap();
let packed = r.pack();
assert_eq!(packed.flags, dim::CREATOR_UID);
assert_eq!(packed.creator_uid, 1000);
assert_eq!(packed.target_filename_hash, 0);
assert_eq!(packed.creator_process_hash, 0);
}
#[test]
fn pack_creator_process_hashes_match_fnv() {
let r = RuleSpec::parse("creator_process=/usr/bin/curl").unwrap();
let packed = r.pack();
assert_eq!(packed.flags, dim::CREATOR_PROCESS);
assert_eq!(packed.creator_process_hash, fnv_hash("/usr/bin/curl"));
}
#[test]
fn rules_dedup_on_canonical_line() {
let mut rules = Rules::default();
assert!(rules.insert(RuleSpec::parse("creator_uid=1000").unwrap()));
assert!(!rules.insert(RuleSpec::parse("creator_uid=1000").unwrap()));
assert_eq!(rules.len(), 1);
}
#[test]
fn rules_load_skips_comments_and_blanks() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "# top comment").unwrap();
writeln!(tmp).unwrap();
writeln!(tmp, "creator_comm=curl # inline comment").unwrap();
writeln!(tmp, "creator_uid=1000;creator_comm=curl").unwrap();
tmp.flush().unwrap();
let rules = Rules::load(tmp.path()).unwrap();
assert_eq!(rules.len(), 2);
}
#[test]
fn capacity_check_fires_above_max_rules() {
let mut rules = Rules::default();
for i in 0..(MAX_RULES + 1) {
let spec = RuleSpec::parse(&format!("creator_uid={i}")).unwrap();
rules.insert(spec);
}
assert!(rules.check_capacity().is_err());
}
}