use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default)]
pub struct PolicyContext {
pub cwd: Option<std::path::PathBuf>,
pub parent_process_name: Option<String>,
pub network_online: Option<bool>,
}
impl PolicyContext {
#[must_use]
pub fn capture() -> Self {
Self {
cwd: std::env::current_dir().ok(),
parent_process_name: parent_process_name(),
network_online: probe_network_state(),
}
}
#[must_use]
pub fn empty() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum NetworkRequirement {
Online,
Offline,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Predicates {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd_glob: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_process_names: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_state: Option<NetworkRequirement>,
}
impl Predicates {
#[must_use]
pub fn matches(&self, ctx: &PolicyContext) -> bool {
if let Some(glob) = &self.cwd_glob {
let Some(cwd) = ctx.cwd.as_deref() else {
return false;
};
if !glob_match(glob, &normalize_path(cwd)) {
return false;
}
}
if let Some(allowed) = &self.parent_process_names {
if !allowed.is_empty() {
let Some(parent) = ctx.parent_process_name.as_deref() else {
return false;
};
if !allowed.iter().any(|a| parent_name_eq(a, parent)) {
return false;
}
}
}
if let Some(req) = self.network_state {
let Some(online) = ctx.network_online else {
return false;
};
match req {
NetworkRequirement::Online if !online => return false,
NetworkRequirement::Offline if online => return false,
_ => {}
}
}
true
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cwd_glob.is_none()
&& self
.parent_process_names
.as_ref()
.is_none_or(Vec::is_empty)
&& self.network_state.is_none()
}
}
fn parent_name_eq(allow_entry: &str, captured: &str) -> bool {
#[cfg(windows)]
{
if captured.eq_ignore_ascii_case(allow_entry) {
return true;
}
let lower = captured.to_ascii_lowercase();
if let Some(stem_lower) = lower.strip_suffix(".exe") {
return stem_lower == allow_entry.to_ascii_lowercase();
}
false
}
#[cfg(not(windows))]
{
captured == allow_entry
}
}
fn normalize_path(p: &Path) -> String {
let s = p.to_string_lossy();
let s = s.replace('\\', "/");
#[cfg(windows)]
{
if let Some((drive, rest)) = s.split_once(':') {
if drive.len() == 1 {
let mut out = String::with_capacity(s.len());
out.push(drive.chars().next().unwrap_or('c').to_ascii_lowercase());
out.push(':');
out.push_str(rest);
return out;
}
}
}
s
}
#[must_use]
pub fn glob_match(pattern: &str, path: &str) -> bool {
glob_match_inner(pattern.as_bytes(), path.as_bytes())
}
fn glob_match_inner(pat: &[u8], s: &[u8]) -> bool {
let mut i = 0usize;
let mut j = 0usize;
let mut star_pat: Option<usize> = None;
let mut star_match: usize = 0;
let mut star_is_double = false;
while j < s.len() {
if i < pat.len() {
match pat[i] {
b'?' => {
if s[j] == b'/' {
} else {
i += 1;
j += 1;
continue;
}
}
b'*' => {
let is_double = i + 1 < pat.len() && pat[i + 1] == b'*';
star_pat = Some(if is_double { i + 2 } else { i + 1 });
star_match = j;
star_is_double = is_double;
i = star_pat.unwrap_or(i + 1);
continue;
}
c if c == s[j] => {
i += 1;
j += 1;
continue;
}
_ => {}
}
}
if let Some(sp) = star_pat {
if !star_is_double && s[star_match] == b'/' {
return false;
}
i = sp;
star_match += 1;
j = star_match;
continue;
}
return false;
}
while i < pat.len() {
if pat[i] == b'*' {
i += 1;
} else {
return false;
}
}
true
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn parent_process_name() -> Option<String> {
let ppid = unsafe { libc::getppid() };
if ppid <= 0 {
return None;
}
let comm_path = format!("/proc/{ppid}/comm");
let raw = std::fs::read_to_string(comm_path).ok()?;
Some(raw.trim().to_string())
}
#[cfg(target_os = "macos")]
#[must_use]
pub fn parent_process_name() -> Option<String> {
use std::process::Command;
let ppid = unsafe { libc::getppid() };
if ppid <= 0 {
return None;
}
let out = Command::new("/bin/ps")
.args(["-p", &ppid.to_string(), "-o", "comm="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
let name = std::path::Path::new(&raw)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&raw)
.to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
#[cfg(target_os = "windows")]
#[must_use]
pub fn parent_process_name() -> Option<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
let our_pid = unsafe { GetCurrentProcessId() };
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snap == INVALID_HANDLE_VALUE {
return None;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut parent_pid: Option<u32> = None;
if unsafe { Process32FirstW(snap, &mut entry) } != 0 {
loop {
if entry.th32ProcessID == our_pid {
parent_pid = Some(entry.th32ParentProcessID);
break;
}
if unsafe { Process32NextW(snap, &mut entry) } == 0 {
break;
}
}
}
let name: Option<String> = if let Some(ppid) = parent_pid {
unsafe { CloseHandle(snap) };
let snap2 = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snap2 == INVALID_HANDLE_VALUE {
return None;
}
let mut e2: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
e2.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut found: Option<String> = None;
if unsafe { Process32FirstW(snap2, &mut e2) } != 0 {
loop {
if e2.th32ProcessID == ppid {
let len = e2.szExeFile.iter().take_while(|&&c| c != 0).count();
let s = String::from_utf16_lossy(&e2.szExeFile[..len]);
if !s.is_empty() {
found = Some(s);
}
break;
}
if unsafe { Process32NextW(snap2, &mut e2) } == 0 {
break;
}
}
}
unsafe { CloseHandle(snap2) };
found
} else {
unsafe { CloseHandle(snap) };
None
};
name
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
#[must_use]
pub fn parent_process_name() -> Option<String> {
None
}
#[must_use]
pub fn probe_network_state() -> Option<bool> {
if std::env::var("ENVSEAL_DISABLE_NETWORK_PROBE")
.is_ok_and(|v| !v.is_empty() && v != "0" && v != "false")
{
return None;
}
use std::net::ToSocketAddrs;
let result = std::thread::spawn(|| ("one.one.one.one", 443u16).to_socket_addrs())
.join()
.ok()?;
match result {
Ok(mut iter) => Some(iter.next().is_some()),
Err(_) => Some(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_basic_literal() {
assert!(glob_match("foo", "foo"));
assert!(!glob_match("foo", "bar"));
assert!(!glob_match("foo", "foobar"));
}
#[test]
fn glob_star_within_segment() {
assert!(glob_match("foo*", "foobar"));
assert!(glob_match("*bar", "foobar"));
assert!(glob_match("f*r", "foobar"));
assert!(!glob_match("foo*", "foo/bar"), "* must not cross /");
}
#[test]
fn glob_double_star_recursive() {
assert!(glob_match("/work/**", "/work/proj/sub/file"));
assert!(glob_match("/work/**", "/work/"));
assert!(glob_match("**", "/anything/here"));
assert!(!glob_match("/work/**", "/elsewhere/proj"));
}
#[test]
fn glob_question_single_char() {
assert!(glob_match("/srv/?/x", "/srv/a/x"));
assert!(!glob_match("/srv/?/x", "/srv/ab/x"));
assert!(!glob_match("/srv/?/x", "/srv/x"));
}
#[test]
fn empty_predicates_match_everything() {
let p = Predicates::default();
let ctx = PolicyContext::empty();
assert!(p.matches(&ctx));
let ctx2 = PolicyContext {
cwd: Some(std::path::PathBuf::from("/anywhere")),
parent_process_name: Some("anything".to_string()),
network_online: Some(true),
};
assert!(p.matches(&ctx2));
}
#[test]
fn cwd_glob_matches_when_path_satisfies() {
let p = Predicates {
cwd_glob: Some("/work/**".to_string()),
..Predicates::default()
};
let ctx = PolicyContext {
cwd: Some(std::path::PathBuf::from("/work/proj/sub")),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
}
#[test]
fn cwd_glob_fails_when_path_diverges() {
let p = Predicates {
cwd_glob: Some("/work/**".to_string()),
..Predicates::default()
};
let ctx = PolicyContext {
cwd: Some(std::path::PathBuf::from("/etc/whatever")),
..PolicyContext::empty()
};
assert!(!p.matches(&ctx));
}
#[test]
fn cwd_glob_fails_closed_when_cwd_unknown() {
let p = Predicates {
cwd_glob: Some("/work/**".to_string()),
..Predicates::default()
};
let ctx = PolicyContext::empty();
assert!(
!p.matches(&ctx),
"missing cwd must not silently match a glob predicate"
);
}
#[test]
fn parent_name_match_basic() {
let p = Predicates {
parent_process_names: Some(vec!["fish".to_string(), "bash".to_string()]),
..Predicates::default()
};
let ctx = PolicyContext {
parent_process_name: Some("fish".to_string()),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
let ctx_bad = PolicyContext {
parent_process_name: Some("cron".to_string()),
..PolicyContext::empty()
};
assert!(!p.matches(&ctx_bad));
}
#[test]
fn parent_name_fails_closed_when_unknown() {
let p = Predicates {
parent_process_names: Some(vec!["fish".to_string()]),
..Predicates::default()
};
let ctx = PolicyContext::empty();
assert!(!p.matches(&ctx));
}
#[test]
fn parent_name_empty_list_means_any() {
let p = Predicates {
parent_process_names: Some(vec![]),
..Predicates::default()
};
let ctx = PolicyContext {
parent_process_name: Some("anything".to_string()),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
}
#[cfg(windows)]
#[test]
fn parent_name_windows_strips_exe_and_folds_case() {
let p = Predicates {
parent_process_names: Some(vec!["Fish".to_string()]),
..Predicates::default()
};
let ctx = PolicyContext {
parent_process_name: Some("FISH.EXE".to_string()),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
}
#[test]
fn network_online_required_passes_when_online() {
let p = Predicates {
network_state: Some(NetworkRequirement::Online),
..Predicates::default()
};
let ctx = PolicyContext {
network_online: Some(true),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
}
#[test]
fn network_online_required_fails_when_offline() {
let p = Predicates {
network_state: Some(NetworkRequirement::Online),
..Predicates::default()
};
let ctx = PolicyContext {
network_online: Some(false),
..PolicyContext::empty()
};
assert!(!p.matches(&ctx));
}
#[test]
fn network_offline_required_passes_when_offline() {
let p = Predicates {
network_state: Some(NetworkRequirement::Offline),
..Predicates::default()
};
let ctx = PolicyContext {
network_online: Some(false),
..PolicyContext::empty()
};
assert!(p.matches(&ctx));
}
#[test]
fn network_unknown_fails_closed() {
for req in [NetworkRequirement::Online, NetworkRequirement::Offline] {
let p = Predicates {
network_state: Some(req),
..Predicates::default()
};
let ctx = PolicyContext::empty();
assert!(
!p.matches(&ctx),
"unknown network state must not silently satisfy a {req:?} predicate"
);
}
}
#[test]
fn predicates_combine_with_and_semantics() {
let p = Predicates {
cwd_glob: Some("/work/**".to_string()),
parent_process_names: Some(vec!["fish".to_string()]),
network_state: Some(NetworkRequirement::Offline),
};
let ctx_pass = PolicyContext {
cwd: Some(std::path::PathBuf::from("/work/proj")),
parent_process_name: Some("fish".to_string()),
network_online: Some(false),
};
assert!(p.matches(&ctx_pass));
let ctx_fail_one = PolicyContext {
cwd: Some(std::path::PathBuf::from("/work/proj")),
parent_process_name: Some("cron".to_string()),
network_online: Some(false),
};
assert!(!p.matches(&ctx_fail_one));
}
#[test]
fn predicates_serde_round_trip() {
let p = Predicates {
cwd_glob: Some("/work/**".to_string()),
parent_process_names: Some(vec!["fish".to_string(), "bash".to_string()]),
network_state: Some(NetworkRequirement::Offline),
};
let toml_str = toml::to_string(&p).unwrap();
let back: Predicates = toml::from_str(&toml_str).unwrap();
assert_eq!(back.cwd_glob, p.cwd_glob);
assert_eq!(back.parent_process_names, p.parent_process_names);
assert_eq!(back.network_state, p.network_state);
}
#[test]
fn predicates_serde_omits_none_fields() {
let p = Predicates::default();
let toml_str = toml::to_string(&p).unwrap();
assert!(
toml_str.trim().is_empty() || !toml_str.contains("cwd_glob"),
"default predicates must not emit fields, got:\n{toml_str}"
);
}
#[test]
fn is_empty_correct() {
assert!(Predicates::default().is_empty());
assert!(!Predicates {
cwd_glob: Some("x".into()),
..Predicates::default()
}
.is_empty());
assert!(!Predicates {
parent_process_names: Some(vec!["x".into()]),
..Predicates::default()
}
.is_empty());
assert!(!Predicates {
network_state: Some(NetworkRequirement::Online),
..Predicates::default()
}
.is_empty());
assert!(Predicates {
parent_process_names: Some(vec![]),
..Predicates::default()
}
.is_empty());
}
}