use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AllowlistLayer {
Project,
User,
System,
}
impl AllowlistLayer {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Project => "project",
Self::User => "user",
Self::System => "system",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RuleId {
pub pack_id: String,
pub pattern_name: String,
}
impl RuleId {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
let (pack_id, pattern_name) = s.split_once(':')?;
let pack_id = pack_id.trim();
let pattern_name = pattern_name.trim();
if pack_id.is_empty() || pattern_name.is_empty() {
return None;
}
if pack_id.contains(char::is_whitespace) || pattern_name.contains(char::is_whitespace) {
return None;
}
Some(Self {
pack_id: pack_id.to_string(),
pattern_name: pattern_name.to_string(),
})
}
}
impl std::fmt::Display for RuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.pack_id, self.pattern_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AllowSelector {
Rule(RuleId),
ExactCommand(String),
CommandPrefix(String),
RegexPattern(String),
}
impl AllowSelector {
#[must_use]
pub const fn kind_label(&self) -> &'static str {
match self {
Self::Rule(_) => "rule",
Self::ExactCommand(_) => "exact_command",
Self::CommandPrefix(_) => "command_prefix",
Self::RegexPattern(_) => "pattern",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowEntry {
pub selector: AllowSelector,
pub reason: String,
pub added_by: Option<String>,
pub added_at: Option<String>,
pub expires_at: Option<String>,
pub ttl: Option<String>,
pub session: Option<bool>,
pub session_id: Option<String>,
pub context: Option<String>,
pub conditions: HashMap<String, String>,
pub environments: Vec<String>,
pub paths: Option<Vec<String>>,
pub risk_acknowledged: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowlistError {
pub layer: AllowlistLayer,
pub path: PathBuf,
pub entry_index: Option<usize>,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct AllowlistFile {
pub entries: Vec<AllowEntry>,
pub errors: Vec<AllowlistError>,
}
#[derive(Debug, Clone)]
pub struct LoadedAllowlistLayer {
pub layer: AllowlistLayer,
pub path: PathBuf,
pub file: AllowlistFile,
}
#[derive(Debug, Clone, Default)]
pub struct LayeredAllowlist {
pub layers: Vec<LoadedAllowlistLayer>,
}
impl LayeredAllowlist {
#[must_use]
pub fn load_from_paths(
project: Option<PathBuf>,
user: Option<PathBuf>,
system: Option<PathBuf>,
) -> Self {
let mut layers: Vec<LoadedAllowlistLayer> = Vec::new();
if let Some(path) = project {
layers.push(LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: path.clone(),
file: load_allowlist_file(AllowlistLayer::Project, &path),
});
}
if let Some(path) = user {
layers.push(LoadedAllowlistLayer {
layer: AllowlistLayer::User,
path: path.clone(),
file: load_allowlist_file(AllowlistLayer::User, &path),
});
}
if let Some(path) = system {
layers.push(LoadedAllowlistLayer {
layer: AllowlistLayer::System,
path: path.clone(),
file: load_allowlist_file(AllowlistLayer::System, &path),
});
}
Self { layers }
}
#[must_use]
pub fn lookup_rule(&self, rule: &RuleId) -> Option<(&AllowEntry, AllowlistLayer)> {
self.lookup_rule_at_path(rule, None)
}
#[must_use]
pub fn match_rule_at_path(
&self,
pack_id: &str,
pattern_name: &str,
cwd: Option<&Path>,
) -> Option<AllowlistHit<'_>> {
if pack_id == "*" {
return None;
}
let current_session_id = current_session_id();
for layer in &self.layers {
for entry in &layer.file.entries {
if !is_entry_valid_at_path_with_session(entry, cwd, current_session_id.as_deref()) {
continue;
}
let AllowSelector::Rule(rule_id) = &entry.selector else {
continue;
};
if rule_id.pack_id != pack_id {
continue;
}
if rule_id.pattern_name == pattern_name || rule_id.pattern_name == "*" {
return Some(AllowlistHit {
layer: layer.layer,
entry,
});
}
}
}
None
}
#[must_use]
pub fn match_rule(&self, pack_id: &str, pattern_name: &str) -> Option<AllowlistHit<'_>> {
self.match_rule_at_path(pack_id, pattern_name, None)
}
#[must_use]
pub fn match_exact_command(&self, command: &str) -> Option<AllowlistHit<'_>> {
self.match_exact_command_at_path(command, None)
}
#[must_use]
pub fn match_command_prefix(&self, command: &str) -> Option<AllowlistHit<'_>> {
self.match_command_prefix_at_path(command, None)
}
#[must_use]
pub fn lookup_rule_at_path(
&self,
rule: &RuleId,
cwd: Option<&Path>,
) -> Option<(&AllowEntry, AllowlistLayer)> {
let current_session_id = current_session_id();
for layer in &self.layers {
for entry in &layer.file.entries {
if !is_entry_valid_at_path_with_session(entry, cwd, current_session_id.as_deref()) {
continue;
}
if let AllowSelector::Rule(rule_id) = &entry.selector {
if rule_id == rule {
return Some((entry, layer.layer));
}
}
}
}
None
}
#[must_use]
pub fn match_exact_command_at_path(
&self,
command: &str,
cwd: Option<&Path>,
) -> Option<AllowlistHit<'_>> {
let current_session_id = current_session_id();
for layer in &self.layers {
for entry in &layer.file.entries {
if !is_entry_valid_at_path_with_session(entry, cwd, current_session_id.as_deref()) {
continue;
}
if let AllowSelector::ExactCommand(cmd) = &entry.selector {
if cmd == command {
return Some(AllowlistHit {
layer: layer.layer,
entry,
});
}
}
}
}
None
}
#[must_use]
pub fn match_command_prefix_at_path(
&self,
command: &str,
cwd: Option<&Path>,
) -> Option<AllowlistHit<'_>> {
let current_session_id = current_session_id();
for layer in &self.layers {
for entry in &layer.file.entries {
if !is_entry_valid_at_path_with_session(entry, cwd, current_session_id.as_deref()) {
continue;
}
if let AllowSelector::CommandPrefix(prefix) = &entry.selector {
if command.starts_with(prefix) {
return Some(AllowlistHit {
layer: layer.layer,
entry,
});
}
}
}
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AllowlistHit<'a> {
pub layer: AllowlistLayer,
pub entry: &'a AllowEntry,
}
#[must_use]
pub fn is_expired(entry: &AllowEntry) -> bool {
if let Some(ref expires_at) = entry.expires_at {
return is_timestamp_expired(expires_at);
}
if let Some(ref ttl) = entry.ttl {
return is_ttl_expired(ttl, entry.added_at.as_deref());
}
false
}
fn is_timestamp_expired(expires_at: &str) -> bool {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(expires_at) {
return dt < chrono::Utc::now();
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(expires_at, "%Y-%m-%dT%H:%M:%S") {
let utc = dt.and_utc();
return utc < chrono::Utc::now();
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(expires_at, "%Y-%m-%d") {
if let Some(end_of_day) = date.and_hms_opt(23, 59, 59) {
return end_of_day.and_utc() < chrono::Utc::now();
}
return true;
}
true
}
fn is_ttl_expired(ttl: &str, added_at: Option<&str>) -> bool {
let Some(added_at) = added_at else {
return true;
};
let added_time = parse_timestamp(added_at);
let Some(added_time) = added_time else {
return true;
};
let Ok(duration) = parse_duration(ttl) else {
return true;
};
let Some(expires_at) = added_time.checked_add_signed(duration) else {
return true;
};
expires_at < chrono::Utc::now()
}
fn parse_timestamp(timestamp: &str) -> Option<chrono::DateTime<chrono::Utc>> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
return Some(dt.with_timezone(&chrono::Utc));
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S") {
return Some(dt.and_utc());
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(timestamp, "%Y-%m-%d") {
if let Some(start_of_day) = date.and_hms_opt(0, 0, 0) {
return Some(start_of_day.and_utc());
}
}
None
}
#[must_use]
pub fn current_session_id() -> Option<String> {
if let Ok(from_env) = std::env::var("DCG_SESSION_ID") {
let trimmed = from_env.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
session_id_from_process_fingerprint()
}
#[must_use]
fn session_id_from_process_fingerprint() -> Option<String> {
#[cfg(target_os = "linux")]
{
let ppid = linux_parent_process_id()?;
let tty = fs::read_link("/proc/self/fd/0")
.ok()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
Some(format!("ppid:{ppid}|tty:{tty}"))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(target_os = "linux")]
#[must_use]
fn linux_parent_process_id() -> Option<u32> {
let stat = fs::read_to_string("/proc/self/stat").ok()?;
let close_paren = stat.rfind(')')?;
let rest = stat.get(close_paren + 2..)?;
let mut parts = rest.split_whitespace();
let _state = parts.next()?;
parts.next()?.parse().ok()
}
#[must_use]
fn session_scope_matches(entry: &AllowEntry, current_session_id: Option<&str>) -> bool {
if entry.session != Some(true) {
return true;
}
let Some(bound_session_id) = entry.session_id.as_deref().map(str::trim) else {
return false;
};
if bound_session_id.is_empty() {
return false;
}
let Some(current_session_id) = current_session_id.map(str::trim) else {
return false;
};
bound_session_id == current_session_id
}
#[must_use]
pub fn conditions_met(entry: &AllowEntry) -> bool {
if entry.conditions.is_empty() {
return true;
}
for (key, expected_value) in &entry.conditions {
match std::env::var(key) {
Ok(actual_value) if actual_value == *expected_value => {}
_ => return false,
}
}
true
}
#[must_use]
pub const fn has_required_risk_ack(entry: &AllowEntry) -> bool {
match &entry.selector {
AllowSelector::RegexPattern(_) => entry.risk_acknowledged,
_ => true, }
}
#[must_use]
pub fn path_matches(entry: &AllowEntry, cwd: &Path) -> bool {
let Some(ref patterns) = entry.paths else {
return true;
};
if patterns.is_empty() {
return true;
}
let cwd_str = cwd.to_string_lossy();
for pattern in patterns {
if pattern == "*" {
return true;
}
match glob::Pattern::new(pattern) {
Ok(glob_pattern) => {
if glob_pattern.matches(&cwd_str) {
return true;
}
if let Ok(canonical) = cwd.canonicalize() {
if glob_pattern.matches(&canonical.to_string_lossy()) {
return true;
}
}
}
Err(e) => {
tracing::warn!(
pattern = pattern,
error = %e,
"invalid glob pattern in allowlist entry, skipping"
);
}
}
}
false
}
#[must_use]
pub fn is_entry_valid(entry: &AllowEntry) -> bool {
let current_session_id = current_session_id();
is_entry_valid_with_session(entry, current_session_id.as_deref())
}
#[must_use]
pub fn is_entry_valid_at_path(entry: &AllowEntry, cwd: Option<&Path>) -> bool {
let current_session_id = current_session_id();
is_entry_valid_at_path_with_session(entry, cwd, current_session_id.as_deref())
}
#[must_use]
fn is_entry_valid_with_session(entry: &AllowEntry, current_session_id: Option<&str>) -> bool {
!is_expired(entry)
&& session_scope_matches(entry, current_session_id)
&& conditions_met(entry)
&& has_required_risk_ack(entry)
}
#[must_use]
fn is_entry_valid_at_path_with_session(
entry: &AllowEntry,
cwd: Option<&Path>,
current_session_id: Option<&str>,
) -> bool {
if !is_entry_valid_with_session(entry, current_session_id) {
return false;
}
let Some(cwd) = cwd else {
return true;
};
let cwd_str = cwd.to_string_lossy();
entry_path_matches(entry, &cwd_str)
}
pub fn validate_expiration_date(timestamp: &str) -> Result<(), String> {
if chrono::DateTime::parse_from_rfc3339(timestamp).is_ok() {
return Ok(());
}
if chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S").is_ok() {
return Ok(());
}
if chrono::NaiveDate::parse_from_str(timestamp, "%Y-%m-%d").is_ok() {
return Ok(());
}
Err(format!(
"Invalid expiration date format: '{timestamp}'. \
Expected ISO 8601 format (e.g., '2030-01-01', '2030-01-01T00:00:00Z')"
))
}
pub fn validate_condition(condition: &str) -> Result<(), String> {
if condition.contains('=') {
let parts: Vec<&str> = condition.splitn(2, '=').collect();
if parts.len() == 2 && !parts[0].trim().is_empty() {
return Ok(());
}
}
Err(format!(
"Invalid condition format: '{condition}'. Expected KEY=VALUE format (e.g., 'CI=true')"
))
}
pub fn parse_duration(s: &str) -> Result<chrono::TimeDelta, String> {
let s = s.trim().to_lowercase();
if s.is_empty() {
return Err("TTL cannot be empty".to_string());
}
let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if digit_end == 0 {
return Err(format!(
"Invalid TTL format: '{s}'. Must start with a number (e.g., '4h', '7d')"
));
}
let num_str = &s[..digit_end];
let unit = s[digit_end..].trim();
let num: i64 = num_str
.parse()
.map_err(|_| format!("Invalid TTL number: '{num_str}'. Number too large or invalid."))?;
if num <= 0 {
return Err(format!("Invalid TTL: '{s}'. Duration must be positive."));
}
let duration = match unit {
"s" | "sec" | "secs" | "second" | "seconds" => chrono::TimeDelta::try_seconds(num),
"m" | "min" | "mins" | "minute" | "minutes" => chrono::TimeDelta::try_minutes(num),
"h" | "hr" | "hrs" | "hour" | "hours" => chrono::TimeDelta::try_hours(num),
"d" | "day" | "days" => chrono::TimeDelta::try_days(num),
"w" | "wk" | "wks" | "week" | "weeks" => chrono::TimeDelta::try_weeks(num),
"" => {
return Err(format!(
"Invalid TTL format: '{s}'. Missing unit (use s, m, h, d, or w)"
));
}
_ => {
return Err(format!(
"Invalid TTL unit: '{unit}'. Valid units: s (seconds), m (minutes), h (hours), d (days), w (weeks)"
));
}
};
duration.ok_or_else(|| format!("TTL overflow: '{s}' exceeds maximum duration"))
}
pub fn validate_ttl(ttl: &str) -> Result<(), String> {
parse_duration(ttl)?;
Ok(())
}
pub fn validate_expiration_exclusivity(
expires_at: Option<&str>,
ttl: Option<&str>,
session: Option<bool>,
) -> Result<(), String> {
let mut count = 0;
if expires_at.is_some() {
count += 1;
}
if ttl.is_some() {
count += 1;
}
if session == Some(true) {
count += 1;
}
if count > 1 {
return Err(
"Invalid entry: only one of expires_at, ttl, or session may be set".to_string(),
);
}
Ok(())
}
pub fn validate_glob_pattern(pattern: &str) -> Result<(), String> {
if pattern.is_empty() {
return Err("path pattern cannot be empty".to_string());
}
glob::Pattern::new(pattern).map_err(|e| format!("invalid glob pattern: {e}"))?;
Ok(())
}
#[must_use]
pub fn path_matches_glob(pattern: &str, path: &str) -> bool {
let normalized_path = path.replace('\\', "/");
let normalized_pattern = pattern.replace('\\', "/");
if normalized_pattern == "*" {
return true;
}
let Ok(compiled) = glob::Pattern::new(&normalized_pattern) else {
return false;
};
let options = glob::MatchOptions {
case_sensitive: cfg!(unix),
require_literal_separator: true,
require_literal_leading_dot: false,
};
compiled.matches_with(&normalized_path, options)
}
#[must_use]
pub fn path_matches_patterns(path: &str, patterns: Option<&[String]>) -> bool {
let Some(patterns) = patterns else {
return true;
};
if patterns.is_empty() || patterns.iter().any(|p| p == "*") {
return true;
}
patterns
.iter()
.any(|pattern| path_matches_glob(pattern, path))
}
#[must_use]
pub fn entry_path_matches(entry: &AllowEntry, path: &str) -> bool {
path_matches_patterns(path, entry.paths.as_deref())
}
pub fn resolve_path_for_matching(
path: &str,
base_dir: Option<&Path>,
resolve_symlinks: bool,
) -> Result<String, String> {
let path = Path::new(path);
let absolute_path = if path.is_relative() {
if let Some(base) = base_dir {
base.join(path)
} else {
std::env::current_dir()
.map_err(|e| format!("failed to get current directory: {e}"))?
.join(path)
}
} else {
path.to_path_buf()
};
let resolved = if resolve_symlinks {
absolute_path.canonicalize().unwrap_or(absolute_path)
} else {
absolute_path
};
Ok(resolved.to_string_lossy().replace('\\', "/"))
}
#[must_use]
pub fn load_default_allowlists() -> LayeredAllowlist {
let project = std::env::current_dir()
.ok()
.and_then(|cwd| find_repo_root(&cwd))
.map(|root| root.join(".dcg").join("allowlist.toml"));
let user = dirs::home_dir()
.map(|h| h.join(".config").join("dcg").join("allowlist.toml"))
.filter(|p| p.exists())
.or_else(|| dirs::config_dir().map(|d| d.join("dcg").join("allowlist.toml")));
let system = std::env::var("DCG_ALLOWLIST_SYSTEM_PATH").map_or_else(
|_| Some(PathBuf::from("/etc/dcg/allowlist.toml")),
|path| {
let trimmed = path.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
},
);
LayeredAllowlist::load_from_paths(project, user, system)
}
fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
fn load_allowlist_file(layer: AllowlistLayer, path: &Path) -> AllowlistFile {
if !path.exists() {
return AllowlistFile::default();
}
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
return AllowlistFile {
entries: Vec::new(),
errors: vec![AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: None,
message: format!("failed to read allowlist file: {e}"),
}],
};
}
};
parse_allowlist_toml(layer, path, &content)
}
pub(crate) fn parse_allowlist_toml(
layer: AllowlistLayer,
path: &Path,
content: &str,
) -> AllowlistFile {
let mut file = AllowlistFile::default();
let value: toml::Value = match toml::from_str(content) {
Ok(v) => v,
Err(e) => {
file.errors.push(AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: None,
message: format!("invalid TOML: {e}"),
});
return file;
}
};
let Some(root) = value.as_table() else {
file.errors.push(AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: None,
message: "allowlist TOML root must be a table".to_string(),
});
return file;
};
let allow_items = root.get("allow");
let Some(allow_items) = allow_items else {
return file;
};
let Some(allow_array) = allow_items.as_array() else {
file.errors.push(AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: None,
message: "`allow` must be an array of tables (use [[allow]])".to_string(),
});
return file;
};
for (idx, item) in allow_array.iter().enumerate() {
let Some(tbl) = item.as_table() else {
file.errors.push(AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: Some(idx),
message: "each [[allow]] entry must be a table".to_string(),
});
continue;
};
match parse_allow_entry(tbl) {
Ok(entry) => file.entries.push(entry),
Err(msg) => file.errors.push(AllowlistError {
layer,
path: path.to_path_buf(),
entry_index: Some(idx),
message: msg,
}),
}
}
file
}
fn parse_allow_entry(tbl: &toml::value::Table) -> Result<AllowEntry, String> {
let reason = match get_string(tbl, "reason") {
Some(s) if !s.trim().is_empty() => s,
_ => return Err("missing required field: reason".to_string()),
};
let rule = get_string(tbl, "rule");
let exact_command = get_string(tbl, "exact_command");
let command_prefix = get_string(tbl, "command_prefix");
let pattern = get_string(tbl, "pattern");
let mut selector: Option<AllowSelector> = None;
let mut selector_count = 0usize;
if let Some(rule) = rule {
selector_count += 1;
let rule_id = RuleId::parse(&rule)
.ok_or_else(|| "invalid rule id (expected pack_id:pattern_name)".to_string())?;
selector = Some(AllowSelector::Rule(rule_id));
}
if let Some(cmd) = exact_command {
selector_count += 1;
selector = Some(AllowSelector::ExactCommand(cmd));
}
if let Some(prefix) = command_prefix {
selector_count += 1;
selector = Some(AllowSelector::CommandPrefix(prefix));
}
if let Some(re) = pattern {
selector_count += 1;
selector = Some(AllowSelector::RegexPattern(re));
}
if selector_count == 0 {
return Err(
"missing selector: one of rule, exact_command, command_prefix, pattern".to_string(),
);
}
if selector_count > 1 {
return Err("invalid entry: specify exactly one selector field".to_string());
}
let added_by = get_string(tbl, "added_by");
let added_at = get_timestamp_string(tbl, "added_at");
let expires_at = get_timestamp_string(tbl, "expires_at");
let ttl = get_string(tbl, "ttl");
let session = tbl.get("session").and_then(toml::Value::as_bool);
let session_id = get_string(tbl, "session_id");
if let Some(ref exp) = expires_at {
validate_expiration_date(exp)?;
}
if let Some(ref ttl_str) = ttl {
validate_ttl(ttl_str)?;
}
validate_expiration_exclusivity(expires_at.as_deref(), ttl.as_deref(), session)?;
if session == Some(true) {
let has_session_id = session_id
.as_deref()
.map(str::trim)
.is_some_and(|v| !v.is_empty());
if !has_session_id {
return Err("session=true requires non-empty session_id".to_string());
}
}
let context = get_string(tbl, "context");
let risk_acknowledged = tbl
.get("risk_acknowledged")
.and_then(toml::Value::as_bool)
.unwrap_or(false);
let environments = match tbl.get("environments") {
None => Vec::new(),
Some(v) => {
let Some(arr) = v.as_array() else {
return Err("environments must be an array of strings".to_string());
};
let mut envs = Vec::new();
for item in arr {
let Some(s) = item.as_str() else {
return Err("environments must be an array of strings".to_string());
};
envs.push(s.to_string());
}
envs
}
};
let conditions = match tbl.get("conditions") {
None => HashMap::new(),
Some(v) => {
let Some(t) = v.as_table() else {
return Err("conditions must be a table of strings".to_string());
};
let mut out: HashMap<String, String> = HashMap::new();
for (k, v) in t {
let Some(s) = v.as_str() else {
return Err("conditions must be a table of strings".to_string());
};
out.insert(k.clone(), s.to_string());
}
out
}
};
let paths = match tbl.get("paths") {
None => None,
Some(v) => {
let Some(arr) = v.as_array() else {
return Err("paths must be an array of strings (glob patterns)".to_string());
};
let mut path_patterns = Vec::new();
for item in arr {
let Some(s) = item.as_str() else {
return Err("paths must be an array of strings (glob patterns)".to_string());
};
if let Err(e) = validate_glob_pattern(s) {
return Err(format!("invalid path glob pattern: {e}"));
}
path_patterns.push(s.to_string());
}
if path_patterns.is_empty() {
None } else {
Some(path_patterns)
}
}
};
let selector = selector.ok_or_else(|| {
"missing selector: one of rule, exact_command, command_prefix, pattern".to_string()
})?;
Ok(AllowEntry {
selector,
reason,
added_by,
added_at,
expires_at,
ttl,
session,
session_id,
context,
conditions,
environments,
paths,
risk_acknowledged,
})
}
fn get_string(tbl: &toml::value::Table, key: &str) -> Option<String> {
tbl.get(key)
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
fn get_timestamp_string(tbl: &toml::value::Table, key: &str) -> Option<String> {
let v = tbl.get(key)?;
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
if let Some(dt) = v.as_datetime() {
return Some(dt.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_allowlist_entries() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "intentional for migrations"
added_by = "alice@example.com"
added_at = "2026-01-08T01:23:45Z"
expires_at = 2026-02-01T00:00:00Z
[[allow]]
exact_command = "rm -rf /tmp/dcg-test-artifacts"
reason = "test cleanup"
[[allow]]
command_prefix = "bd create"
context = "string-argument"
reason = "docs-only args"
[[allow]]
pattern = "echo\\s+\\\"Example:.*rm -rf.*\\\""
reason = "documentation examples"
risk_acknowledged = true
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(
file.errors.is_empty(),
"expected no errors, got: {:#?}",
file.errors
);
assert_eq!(file.entries.len(), 4);
}
#[test]
fn invalid_toml_is_non_fatal() {
let file = parse_allowlist_toml(
AllowlistLayer::User,
Path::new("dummy"),
"this is not = valid toml [",
);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(file.errors[0].message.contains("invalid TOML"));
}
#[test]
fn missing_reason_is_flagged() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(
file.errors[0]
.message
.contains("missing required field: reason")
);
}
#[test]
fn missing_selector_is_flagged() {
let toml = r#"
[[allow]]
reason = "no selector here"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(file.errors[0].message.contains("missing selector"));
}
#[test]
fn multiple_selectors_are_flagged() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
exact_command = "git reset --hard"
reason = "too broad"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(file.errors[0].message.contains("exactly one selector"));
}
#[test]
fn invalid_expiration_date_is_flagged() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "test"
expires_at = "not-a-date"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(
file.errors[0]
.message
.contains("Invalid expiration date format")
);
}
#[test]
fn session_entry_without_session_id_is_flagged() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "session rule"
session = true
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.entries.is_empty());
assert_eq!(file.errors.len(), 1);
assert!(
file.errors[0]
.message
.contains("session=true requires non-empty session_id")
);
}
#[test]
fn session_entry_with_session_id_parses() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "session rule"
session = true
session_id = "ppid:123|tty:/dev/pts/0"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(file.errors.is_empty());
assert_eq!(file.entries.len(), 1);
assert_eq!(file.entries[0].session, Some(true));
assert_eq!(
file.entries[0].session_id.as_deref(),
Some("ppid:123|tty:/dev/pts/0")
);
}
#[test]
fn precedence_project_over_user_for_rule_lookup() {
let rule = RuleId::parse("core.git:reset-hard").unwrap();
let project_toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "project reason"
"#;
let user_toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "user reason"
"#;
let project_file =
parse_allowlist_toml(AllowlistLayer::Project, Path::new("project"), project_toml);
let user_file = parse_allowlist_toml(AllowlistLayer::User, Path::new("user"), user_toml);
let allowlists = LayeredAllowlist {
layers: vec![
LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project"),
file: project_file,
},
LoadedAllowlistLayer {
layer: AllowlistLayer::User,
path: PathBuf::from("user"),
file: user_file,
},
],
};
let (entry, layer) = allowlists.lookup_rule(&rule).expect("must find rule");
assert_eq!(layer, AllowlistLayer::Project);
assert_eq!(entry.reason, "project reason");
}
#[test]
fn wildcard_pack_rule_matches_any_pattern_in_pack() {
let allowlists = LayeredAllowlist {
layers: vec![LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project"),
file: AllowlistFile {
entries: vec![AllowEntry {
selector: AllowSelector::Rule(RuleId {
pack_id: "core.git".to_string(),
pattern_name: "*".to_string(),
}),
reason: "allow all git rules in this pack".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}],
errors: Vec::new(),
},
}],
};
let hit = allowlists
.match_rule("core.git", "reset-hard")
.expect("wildcard should match");
assert_eq!(hit.layer, AllowlistLayer::Project);
assert_eq!(hit.entry.reason, "allow all git rules in this pack");
}
fn make_test_entry() -> AllowEntry {
AllowEntry {
selector: AllowSelector::Rule(RuleId {
pack_id: "core.git".to_string(),
pattern_name: "reset-hard".to_string(),
}),
reason: "test".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}
}
#[test]
fn entry_without_expiration_is_not_expired() {
let entry = make_test_entry();
assert!(!is_expired(&entry));
}
#[test]
fn entry_with_future_rfc3339_is_not_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2099-12-31T23:59:59Z".to_string());
assert!(!is_expired(&entry));
}
#[test]
fn entry_with_past_rfc3339_is_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2020-01-01T00:00:00Z".to_string());
assert!(is_expired(&entry));
}
#[test]
fn entry_with_future_iso8601_no_tz_is_not_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2099-12-31T23:59:59".to_string());
assert!(!is_expired(&entry));
}
#[test]
fn entry_with_past_iso8601_no_tz_is_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2020-01-01T00:00:00".to_string());
assert!(is_expired(&entry));
}
#[test]
fn entry_with_future_date_only_is_not_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2099-12-31".to_string());
assert!(!is_expired(&entry));
}
#[test]
fn entry_with_past_date_only_is_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("2020-01-01".to_string());
assert!(is_expired(&entry));
}
#[test]
fn entry_with_invalid_timestamp_is_expired() {
let mut entry = make_test_entry();
entry.expires_at = Some("not-a-date".to_string());
assert!(is_expired(&entry));
}
#[test]
fn ttl_entry_without_added_at_is_expired() {
let mut entry = make_test_entry();
entry.ttl = Some("4h".to_string());
entry.added_at = None;
assert!(is_expired(&entry));
}
#[test]
fn ttl_entry_with_future_expiration_is_not_expired() {
let mut entry = make_test_entry();
entry.ttl = Some("24h".to_string());
let added = chrono::Utc::now() - chrono::TimeDelta::try_hours(1).unwrap();
entry.added_at = Some(added.to_rfc3339());
assert!(!is_expired(&entry));
}
#[test]
fn ttl_entry_with_past_expiration_is_expired() {
let mut entry = make_test_entry();
entry.ttl = Some("1h".to_string());
let added = chrono::Utc::now() - chrono::TimeDelta::try_hours(2).unwrap();
entry.added_at = Some(added.to_rfc3339());
assert!(is_expired(&entry));
}
#[test]
fn ttl_entry_with_invalid_ttl_is_expired() {
let mut entry = make_test_entry();
entry.ttl = Some("invalid-ttl".to_string());
entry.added_at = Some(chrono::Utc::now().to_rfc3339());
assert!(is_expired(&entry));
}
#[test]
fn ttl_entry_with_invalid_added_at_is_expired() {
let mut entry = make_test_entry();
entry.ttl = Some("4h".to_string());
entry.added_at = Some("not-a-timestamp".to_string());
assert!(is_expired(&entry));
}
#[test]
fn session_entry_is_not_expired_by_is_expired_check() {
let mut entry = make_test_entry();
entry.session = Some(true);
assert!(!is_expired(&entry));
}
#[test]
fn session_false_entry_is_not_session_scoped() {
let mut entry = make_test_entry();
entry.session = Some(false);
assert!(!is_expired(&entry));
}
#[test]
fn session_scoped_entry_without_bound_session_id_is_invalid() {
let mut entry = make_test_entry();
entry.session = Some(true);
entry.session_id = None;
assert!(!is_entry_valid_with_session(
&entry,
Some("ppid:1|tty:/dev/pts/1")
));
}
#[test]
fn session_scoped_entry_with_mismatched_session_id_is_invalid() {
let mut entry = make_test_entry();
entry.session = Some(true);
entry.session_id = Some("ppid:111|tty:/dev/pts/1".to_string());
assert!(!is_entry_valid_with_session(
&entry,
Some("ppid:222|tty:/dev/pts/2")
));
}
#[test]
fn session_scoped_entry_with_matching_session_id_is_valid() {
let mut entry = make_test_entry();
entry.session = Some(true);
entry.session_id = Some("ppid:111|tty:/dev/pts/1".to_string());
assert!(is_entry_valid_with_session(
&entry,
Some("ppid:111|tty:/dev/pts/1"),
));
}
#[test]
fn parse_duration_minutes() {
assert!(parse_duration("30m").is_ok());
assert!(parse_duration("30min").is_ok());
assert!(parse_duration("30mins").is_ok());
assert!(parse_duration("30minute").is_ok());
assert!(parse_duration("30minutes").is_ok());
assert_eq!(
parse_duration("30m").unwrap(),
chrono::TimeDelta::try_minutes(30).unwrap()
);
}
#[test]
fn parse_duration_hours() {
assert!(parse_duration("4h").is_ok());
assert!(parse_duration("4hr").is_ok());
assert!(parse_duration("4hrs").is_ok());
assert!(parse_duration("4hour").is_ok());
assert!(parse_duration("4hours").is_ok());
assert_eq!(
parse_duration("4h").unwrap(),
chrono::TimeDelta::try_hours(4).unwrap()
);
}
#[test]
fn parse_duration_days() {
assert!(parse_duration("7d").is_ok());
assert!(parse_duration("7day").is_ok());
assert!(parse_duration("7days").is_ok());
assert_eq!(
parse_duration("7d").unwrap(),
chrono::TimeDelta::try_days(7).unwrap()
);
}
#[test]
fn parse_duration_weeks() {
assert!(parse_duration("1w").is_ok());
assert!(parse_duration("1wk").is_ok());
assert!(parse_duration("1wks").is_ok());
assert!(parse_duration("1week").is_ok());
assert!(parse_duration("1weeks").is_ok());
assert_eq!(
parse_duration("1w").unwrap(),
chrono::TimeDelta::try_weeks(1).unwrap()
);
}
#[test]
fn parse_duration_invalid_formats() {
assert!(parse_duration("").is_err());
assert!(parse_duration("h").is_err()); assert!(parse_duration("4").is_err()); assert!(parse_duration("4x").is_err()); assert!(parse_duration("-4h").is_err()); assert!(parse_duration("0h").is_err()); }
#[test]
fn validate_expiration_exclusivity_none_set() {
assert!(validate_expiration_exclusivity(None, None, None).is_ok());
}
#[test]
fn validate_expiration_exclusivity_expires_only() {
assert!(validate_expiration_exclusivity(Some("2030-01-01"), None, None).is_ok());
}
#[test]
fn validate_expiration_exclusivity_ttl_only() {
assert!(validate_expiration_exclusivity(None, Some("4h"), None).is_ok());
}
#[test]
fn validate_expiration_exclusivity_session_only() {
assert!(validate_expiration_exclusivity(None, None, Some(true)).is_ok());
}
#[test]
fn validate_expiration_exclusivity_session_false_ok() {
assert!(validate_expiration_exclusivity(Some("2030-01-01"), None, Some(false)).is_ok());
}
#[test]
fn validate_expiration_exclusivity_multiple_fails() {
assert!(validate_expiration_exclusivity(Some("2030-01-01"), Some("4h"), None).is_err());
assert!(validate_expiration_exclusivity(Some("2030-01-01"), None, Some(true)).is_err());
assert!(validate_expiration_exclusivity(None, Some("4h"), Some(true)).is_err());
assert!(
validate_expiration_exclusivity(Some("2030-01-01"), Some("4h"), Some(true)).is_err()
);
}
#[test]
fn expired_entry_is_skipped_in_match_rule() {
let allowlists = LayeredAllowlist {
layers: vec![LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project"),
file: AllowlistFile {
entries: vec![AllowEntry {
selector: AllowSelector::Rule(RuleId {
pack_id: "core.git".to_string(),
pattern_name: "reset-hard".to_string(),
}),
reason: "expired allowlist".to_string(),
added_by: None,
added_at: None,
expires_at: Some("2020-01-01T00:00:00Z".to_string()),
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}],
errors: Vec::new(),
},
}],
};
assert!(allowlists.match_rule("core.git", "reset-hard").is_none());
}
#[test]
fn entry_with_no_conditions_is_valid() {
let entry = make_test_entry();
assert!(conditions_met(&entry));
}
#[test]
fn entry_with_missing_env_var_is_invalid() {
let mut entry = make_test_entry();
entry.conditions.insert(
"DCG_TEST_NONEXISTENT_VAR_12345_ABCDE".to_string(),
"anything".to_string(),
);
assert!(!conditions_met(&entry));
}
#[test]
fn entry_with_multiple_missing_conditions_is_invalid() {
let mut entry = make_test_entry();
entry.conditions.insert(
"DCG_TEST_MISSING_A_99999".to_string(),
"value_a".to_string(),
);
entry.conditions.insert(
"DCG_TEST_MISSING_B_99999".to_string(),
"value_b".to_string(),
);
assert!(!conditions_met(&entry));
}
#[test]
fn rule_entry_without_risk_ack_is_valid() {
let entry = make_test_entry();
assert!(has_required_risk_ack(&entry));
}
#[test]
fn regex_entry_without_risk_ack_is_invalid() {
let entry = AllowEntry {
selector: AllowSelector::RegexPattern("rm.*-rf".to_string()),
reason: "test".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
};
assert!(!has_required_risk_ack(&entry));
}
#[test]
fn regex_entry_with_risk_ack_is_valid() {
let entry = AllowEntry {
selector: AllowSelector::RegexPattern("rm.*-rf".to_string()),
reason: "test".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: true,
};
assert!(has_required_risk_ack(&entry));
}
#[test]
fn is_entry_valid_combines_all_checks() {
let entry = make_test_entry();
assert!(is_entry_valid(&entry));
let mut expired = make_test_entry();
expired.expires_at = Some("2020-01-01".to_string());
assert!(!is_entry_valid(&expired));
let mut unmet_condition = make_test_entry();
unmet_condition.conditions.insert(
"DCG_TEST_COMBINED_NONEXISTENT_77777".to_string(),
"x".to_string(),
);
assert!(!is_entry_valid(&unmet_condition));
let regex_no_ack = AllowEntry {
selector: AllowSelector::RegexPattern(".*".to_string()),
reason: "test".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
};
assert!(!is_entry_valid(®ex_no_ack));
}
#[test]
fn unmet_condition_entry_is_skipped_in_match_rule() {
let allowlists = LayeredAllowlist {
layers: vec![LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project"),
file: AllowlistFile {
entries: vec![AllowEntry {
selector: AllowSelector::Rule(RuleId {
pack_id: "core.git".to_string(),
pattern_name: "reset-hard".to_string(),
}),
reason: "conditional allowlist".to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: {
let mut m = HashMap::new();
m.insert(
"DCG_TEST_SKIP_NONEXISTENT_88888".to_string(),
"enabled".to_string(),
);
m
},
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}],
errors: Vec::new(),
},
}],
};
assert!(allowlists.match_rule("core.git", "reset-hard").is_none());
}
#[test]
fn test_validate_expiration_date_valid_formats() {
assert!(validate_expiration_date("2030-01-01T00:00:00Z").is_ok());
assert!(validate_expiration_date("2030-01-01T00:00:00+00:00").is_ok());
assert!(validate_expiration_date("2030-01-01T00:00:00").is_ok());
assert!(validate_expiration_date("2030-01-01").is_ok());
}
#[test]
fn test_validate_expiration_date_invalid_formats() {
assert!(validate_expiration_date("not-a-date").is_err());
assert!(validate_expiration_date("01/01/2030").is_err());
assert!(validate_expiration_date("").is_err());
}
#[test]
fn test_validate_condition_valid() {
assert!(validate_condition("CI=true").is_ok());
assert!(validate_condition("ENV=production").is_ok());
assert!(validate_condition("KEY=value with spaces").is_ok());
assert!(validate_condition("EMPTY=").is_ok()); }
#[test]
fn test_validate_condition_invalid() {
assert!(validate_condition("invalid").is_err());
assert!(validate_condition("=value").is_err());
assert!(validate_condition("=").is_err());
}
#[test]
fn test_validate_glob_pattern_valid() {
assert!(validate_glob_pattern("*").is_ok());
assert!(validate_glob_pattern("**").is_ok());
assert!(validate_glob_pattern("/home/**/projects/*").is_ok());
assert!(validate_glob_pattern("*.rs").is_ok());
assert!(validate_glob_pattern("/workspace/[abc]/*.rs").is_ok());
}
#[test]
fn test_validate_glob_pattern_invalid() {
assert!(validate_glob_pattern("").is_err()); assert!(validate_glob_pattern("[abc").is_err()); }
#[test]
fn test_path_matches_glob_star_any() {
assert!(path_matches_glob("*", "/any/path/here"));
assert!(path_matches_glob("*", "file.rs"));
}
#[test]
fn test_path_matches_glob_single_star() {
assert!(path_matches_glob("*.rs", "foo.rs"));
assert!(path_matches_glob("*.rs", "bar.rs"));
assert!(!path_matches_glob("*.rs", "foo/bar.rs")); assert!(!path_matches_glob("*.rs", "foo.txt"));
}
#[test]
fn test_path_matches_glob_double_star() {
assert!(path_matches_glob("**/*.rs", "foo.rs"));
assert!(path_matches_glob("**/*.rs", "src/foo.rs"));
assert!(path_matches_glob("**/*.rs", "src/lib/foo.rs"));
assert!(!path_matches_glob("**/*.rs", "foo.txt"));
}
#[test]
fn test_path_matches_glob_question_mark() {
assert!(path_matches_glob("foo?.rs", "foo1.rs"));
assert!(path_matches_glob("foo?.rs", "foox.rs"));
assert!(!path_matches_glob("foo?.rs", "foo12.rs")); }
#[test]
fn test_path_matches_glob_character_class() {
assert!(path_matches_glob("test[123].rs", "test1.rs"));
assert!(path_matches_glob("test[123].rs", "test2.rs"));
assert!(!path_matches_glob("test[123].rs", "test4.rs"));
}
#[test]
fn test_path_matches_glob_real_paths() {
assert!(path_matches_glob("src/**/*.rs", "src/main.rs"));
assert!(path_matches_glob("src/**/*.rs", "src/lib/mod.rs"));
assert!(!path_matches_glob("src/**/*.rs", "tests/test.rs"));
}
#[test]
fn test_path_matches_glob_windows_separators() {
assert!(path_matches_glob("src/**/*.rs", "src\\lib\\mod.rs"));
}
#[test]
fn test_path_matches_patterns_none() {
assert!(path_matches_patterns("/any/path", None));
}
#[test]
fn test_path_matches_patterns_empty() {
let patterns: Vec<String> = vec![];
assert!(path_matches_patterns("/any/path", Some(&patterns)));
}
#[test]
fn test_path_matches_patterns_explicit_global() {
let patterns = vec!["*".to_string()];
assert!(path_matches_patterns("/any/path", Some(&patterns)));
}
#[test]
fn test_path_matches_patterns_specific() {
let patterns = vec![
"/home/*/projects/**".to_string(),
"/workspace/**".to_string(),
];
assert!(path_matches_patterns(
"/home/user/projects/app",
Some(&patterns)
));
assert!(path_matches_patterns(
"/workspace/src/main.rs",
Some(&patterns)
));
assert!(!path_matches_patterns("/var/log/app.log", Some(&patterns)));
}
#[test]
fn test_entry_path_matches_global() {
let entry = make_test_entry();
assert!(entry_path_matches(&entry, "/any/path"));
assert!(entry_path_matches(&entry, "relative/path"));
}
#[test]
fn test_entry_path_matches_specific() {
let mut entry = make_test_entry();
entry.paths = Some(vec!["/home/*/projects/**".to_string()]);
assert!(entry_path_matches(&entry, "/home/user/projects/app"));
assert!(!entry_path_matches(&entry, "/var/log/app.log"));
}
#[test]
fn test_parses_allowlist_with_paths() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "allow in specific directories"
paths = ["/home/*/projects/*", "/workspace/**"]
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert!(
file.errors.is_empty(),
"expected no errors, got: {:#?}",
file.errors
);
assert_eq!(file.entries.len(), 1);
let entry = &file.entries[0];
let paths = entry.paths.as_ref().expect("paths should be set");
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], "/home/*/projects/*");
assert_eq!(paths[1], "/workspace/**");
}
#[test]
fn test_parses_allowlist_invalid_paths_not_array() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "test"
paths = "/not/an/array"
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert_eq!(file.entries.len(), 0);
assert_eq!(file.errors.len(), 1);
assert!(file.errors[0].message.contains("paths must be an array"));
}
#[test]
fn test_parses_allowlist_invalid_glob_pattern() {
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "test"
paths = ["[unclosed"]
"#;
let file = parse_allowlist_toml(AllowlistLayer::Project, Path::new("dummy"), toml);
assert_eq!(file.entries.len(), 0);
assert_eq!(file.errors.len(), 1);
assert!(file.errors[0].message.contains("invalid"));
}
}