use chrono::{DateTime, Duration, Utc};
use crate::error::MurkError;
use crate::types;
pub fn validate_grant_name(name: &str) -> Result<(), MurkError> {
if name.is_empty() {
return Err(MurkError::Grant("grant name cannot be empty".into()));
}
if name.len() > 64 {
return Err(MurkError::Grant(
"grant name too long (max 64 characters)".into(),
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(MurkError::Grant(format!(
"invalid grant name \"{name}\" — use letters, digits, dashes, underscores"
)));
}
Ok(())
}
pub fn parse_ttl(s: &str) -> Result<Duration, MurkError> {
let s = s.trim();
let (num, unit) = s.split_at(
s.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| MurkError::Grant(format!("ttl \"{s}\" needs a unit: s, m, h, or d")))?,
);
let n: i64 = num
.parse()
.map_err(|_| MurkError::Grant(format!("invalid ttl \"{s}\" — use e.g. 30m, 2h, 7d")))?;
if n <= 0 {
return Err(MurkError::Grant("ttl must be positive".into()));
}
let dur = match unit {
"s" => Duration::seconds(n),
"m" => Duration::minutes(n),
"h" => Duration::hours(n),
"d" => Duration::days(n),
other => {
return Err(MurkError::Grant(format!(
"unknown ttl unit \"{other}\" — use s, m, h, or d"
)));
}
};
Ok(dur)
}
pub fn create_grant(
current: &mut types::Murk,
name: &str,
agent_pubkey: &str,
scope: &[String],
issuer_pubkey: &str,
issued_at: DateTime<Utc>,
ttl: Duration,
) -> Result<types::GrantEntry, MurkError> {
validate_grant_name(name)?;
if current.grants.contains_key(name) {
return Err(MurkError::Grant(format!("grant already exists: {name}")));
}
if scope.is_empty() {
return Err(MurkError::Grant(
"a grant needs at least one key — pass --only KEY".into(),
));
}
let mut scope_sorted = scope.to_vec();
scope_sorted.sort();
scope_sorted.dedup();
for key in &scope_sorted {
let value = current.values.get(key).ok_or_else(|| {
MurkError::Grant(format!(
"cannot grant {key}: no shared value to grant (unknown key, or it is group/scoped-only)"
))
})?;
current
.private
.entry(key.clone())
.or_default()
.insert(agent_pubkey.to_string(), value.clone());
}
let entry = types::GrantEntry {
pubkey: agent_pubkey.to_string(),
scope: scope_sorted,
issued_at: fmt_ts(issued_at),
expires_at: fmt_ts(issued_at + ttl),
issuer: issuer_pubkey.to_string(),
};
current.grants.insert(name.to_string(), entry.clone());
Ok(entry)
}
pub fn remove_grant(current: &mut types::Murk, name: &str) -> Result<types::GrantEntry, MurkError> {
current
.grants
.remove(name)
.ok_or_else(|| MurkError::Grant(format!("grant not found: {name}")))
}
fn fmt_ts(dt: DateTime<Utc>) -> String {
dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use zeroize::Zeroizing;
fn murk_with(keys: &[(&str, &str)]) -> types::Murk {
let mut m = types::Murk::default();
for (k, v) in keys {
m.values
.insert((*k).to_string(), Zeroizing::new((*v).to_string()));
}
m
}
#[test]
fn parse_ttl_units() {
assert_eq!(parse_ttl("90s").unwrap(), Duration::seconds(90));
assert_eq!(parse_ttl("30m").unwrap(), Duration::minutes(30));
assert_eq!(parse_ttl("2h").unwrap(), Duration::hours(2));
assert_eq!(parse_ttl("7d").unwrap(), Duration::days(7));
}
#[test]
fn parse_ttl_rejects_bad_input() {
assert!(parse_ttl("2").is_err()); assert!(parse_ttl("0h").is_err()); assert!(parse_ttl("-1h").is_err()); assert!(parse_ttl("2y").is_err()); assert!(parse_ttl("abc").is_err());
}
#[test]
fn validate_grant_name_rules() {
assert!(validate_grant_name("codex-debug").is_ok());
assert!(validate_grant_name("").is_err());
assert!(validate_grant_name("has space").is_err());
assert!(validate_grant_name(&"x".repeat(65)).is_err());
}
#[test]
fn create_grant_encrypts_scope_and_records_metadata() {
let mut current = murk_with(&[("STRIPE_KEY", "sk_live_1"), ("OTHER", "v")]);
let issued = DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let entry = create_grant(
&mut current,
"codex",
"age1agent",
&["STRIPE_KEY".into()],
"age1owner",
issued,
Duration::hours(2),
)
.unwrap();
assert_eq!(entry.pubkey, "age1agent");
assert_eq!(entry.scope, vec!["STRIPE_KEY".to_string()]);
assert_eq!(entry.issued_at, "2026-06-16T00:00:00Z");
assert_eq!(entry.expires_at, "2026-06-16T02:00:00Z");
assert_eq!(entry.issuer, "age1owner");
assert_eq!(
current.private["STRIPE_KEY"]["age1agent"].as_str(),
"sk_live_1"
);
assert!(!current.private.contains_key("OTHER"));
assert!(current.grants.contains_key("codex"));
}
#[test]
fn create_grant_rejects_unknown_key() {
let mut current = murk_with(&[("STRIPE_KEY", "sk")]);
let issued = Utc::now();
let err = create_grant(
&mut current,
"codex",
"age1agent",
&["NOPE".into()],
"age1owner",
issued,
Duration::hours(1),
)
.unwrap_err();
assert!(err.to_string().contains("NOPE"));
assert!(current.grants.is_empty());
}
#[test]
fn create_grant_rejects_duplicate_name() {
let mut current = murk_with(&[("K", "v")]);
let issued = Utc::now();
create_grant(
&mut current,
"dup",
"age1a",
&["K".into()],
"age1owner",
issued,
Duration::hours(1),
)
.unwrap();
assert!(
create_grant(
&mut current,
"dup",
"age1b",
&["K".into()],
"age1owner",
issued,
Duration::hours(1),
)
.is_err()
);
}
#[test]
fn remove_grant_returns_metadata() {
let mut current = murk_with(&[("K", "v")]);
create_grant(
&mut current,
"g",
"age1a",
&["K".into()],
"age1owner",
Utc::now(),
Duration::hours(1),
)
.unwrap();
let removed = remove_grant(&mut current, "g").unwrap();
assert_eq!(removed.pubkey, "age1a");
assert!(current.grants.is_empty());
assert!(remove_grant(&mut current, "g").is_err());
}
}