1use chrono::{DateTime, Duration, Utc};
18
19use crate::error::MurkError;
20use crate::types;
21
22pub fn validate_grant_name(name: &str) -> Result<(), MurkError> {
24 if name.is_empty() {
25 return Err(MurkError::Grant("grant name cannot be empty".into()));
26 }
27 if name.len() > 64 {
28 return Err(MurkError::Grant(
29 "grant name too long (max 64 characters)".into(),
30 ));
31 }
32 if !name
33 .chars()
34 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
35 {
36 return Err(MurkError::Grant(format!(
37 "invalid grant name \"{name}\" — use letters, digits, dashes, underscores"
38 )));
39 }
40 Ok(())
41}
42
43pub fn parse_ttl(s: &str) -> Result<Duration, MurkError> {
46 let s = s.trim();
47 let (num, unit) = s.split_at(
48 s.find(|c: char| !c.is_ascii_digit())
49 .ok_or_else(|| MurkError::Grant(format!("ttl \"{s}\" needs a unit: s, m, h, or d")))?,
50 );
51 let n: i64 = num
52 .parse()
53 .map_err(|_| MurkError::Grant(format!("invalid ttl \"{s}\" — use e.g. 30m, 2h, 7d")))?;
54 if n <= 0 {
55 return Err(MurkError::Grant("ttl must be positive".into()));
56 }
57 let dur = match unit {
58 "s" => Duration::seconds(n),
59 "m" => Duration::minutes(n),
60 "h" => Duration::hours(n),
61 "d" => Duration::days(n),
62 other => {
63 return Err(MurkError::Grant(format!(
64 "unknown ttl unit \"{other}\" — use s, m, h, or d"
65 )));
66 }
67 };
68 Ok(dur)
69}
70
71pub fn create_grant(
80 current: &mut types::Murk,
81 name: &str,
82 agent_pubkey: &str,
83 scope: &[String],
84 issuer_pubkey: &str,
85 issued_at: DateTime<Utc>,
86 ttl: Duration,
87) -> Result<types::GrantEntry, MurkError> {
88 validate_grant_name(name)?;
89 if current.grants.contains_key(name) {
90 return Err(MurkError::Grant(format!("grant already exists: {name}")));
91 }
92 if scope.is_empty() {
93 return Err(MurkError::Grant(
94 "a grant needs at least one key — pass --only KEY".into(),
95 ));
96 }
97
98 let mut scope_sorted = scope.to_vec();
101 scope_sorted.sort();
102 scope_sorted.dedup();
103
104 for key in &scope_sorted {
105 let value = current.values.get(key).ok_or_else(|| {
106 MurkError::Grant(format!(
107 "cannot grant {key}: no shared value to grant (unknown key, or it is group/scoped-only)"
108 ))
109 })?;
110 current
111 .private
112 .entry(key.clone())
113 .or_default()
114 .insert(agent_pubkey.to_string(), value.clone());
115 }
116
117 let entry = types::GrantEntry {
118 pubkey: agent_pubkey.to_string(),
119 scope: scope_sorted,
120 issued_at: fmt_ts(issued_at),
121 expires_at: fmt_ts(issued_at + ttl),
122 issuer: issuer_pubkey.to_string(),
123 };
124 current.grants.insert(name.to_string(), entry.clone());
125 Ok(entry)
126}
127
128pub fn remove_grant(current: &mut types::Murk, name: &str) -> Result<types::GrantEntry, MurkError> {
131 current
132 .grants
133 .remove(name)
134 .ok_or_else(|| MurkError::Grant(format!("grant not found: {name}")))
135}
136
137fn fmt_ts(dt: DateTime<Utc>) -> String {
140 dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use zeroize::Zeroizing;
147
148 fn murk_with(keys: &[(&str, &str)]) -> types::Murk {
149 let mut m = types::Murk::default();
150 for (k, v) in keys {
151 m.values
152 .insert((*k).to_string(), Zeroizing::new((*v).to_string()));
153 }
154 m
155 }
156
157 #[test]
158 fn parse_ttl_units() {
159 assert_eq!(parse_ttl("90s").unwrap(), Duration::seconds(90));
160 assert_eq!(parse_ttl("30m").unwrap(), Duration::minutes(30));
161 assert_eq!(parse_ttl("2h").unwrap(), Duration::hours(2));
162 assert_eq!(parse_ttl("7d").unwrap(), Duration::days(7));
163 }
164
165 #[test]
166 fn parse_ttl_rejects_bad_input() {
167 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());
172 }
173
174 #[test]
175 fn validate_grant_name_rules() {
176 assert!(validate_grant_name("codex-debug").is_ok());
177 assert!(validate_grant_name("").is_err());
178 assert!(validate_grant_name("has space").is_err());
179 assert!(validate_grant_name(&"x".repeat(65)).is_err());
180 }
181
182 #[test]
183 fn create_grant_encrypts_scope_and_records_metadata() {
184 let mut current = murk_with(&[("STRIPE_KEY", "sk_live_1"), ("OTHER", "v")]);
185 let issued = DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z")
186 .unwrap()
187 .with_timezone(&Utc);
188 let entry = create_grant(
189 &mut current,
190 "codex",
191 "age1agent",
192 &["STRIPE_KEY".into()],
193 "age1owner",
194 issued,
195 Duration::hours(2),
196 )
197 .unwrap();
198
199 assert_eq!(entry.pubkey, "age1agent");
200 assert_eq!(entry.scope, vec!["STRIPE_KEY".to_string()]);
201 assert_eq!(entry.issued_at, "2026-06-16T00:00:00Z");
202 assert_eq!(entry.expires_at, "2026-06-16T02:00:00Z");
203 assert_eq!(entry.issuer, "age1owner");
204
205 assert_eq!(
207 current.private["STRIPE_KEY"]["age1agent"].as_str(),
208 "sk_live_1"
209 );
210 assert!(!current.private.contains_key("OTHER"));
211 assert!(current.grants.contains_key("codex"));
212 }
213
214 #[test]
215 fn create_grant_rejects_unknown_key() {
216 let mut current = murk_with(&[("STRIPE_KEY", "sk")]);
217 let issued = Utc::now();
218 let err = create_grant(
219 &mut current,
220 "codex",
221 "age1agent",
222 &["NOPE".into()],
223 "age1owner",
224 issued,
225 Duration::hours(1),
226 )
227 .unwrap_err();
228 assert!(err.to_string().contains("NOPE"));
229 assert!(current.grants.is_empty());
231 }
232
233 #[test]
234 fn create_grant_rejects_duplicate_name() {
235 let mut current = murk_with(&[("K", "v")]);
236 let issued = Utc::now();
237 create_grant(
238 &mut current,
239 "dup",
240 "age1a",
241 &["K".into()],
242 "age1owner",
243 issued,
244 Duration::hours(1),
245 )
246 .unwrap();
247 assert!(
248 create_grant(
249 &mut current,
250 "dup",
251 "age1b",
252 &["K".into()],
253 "age1owner",
254 issued,
255 Duration::hours(1),
256 )
257 .is_err()
258 );
259 }
260
261 #[test]
262 fn remove_grant_returns_metadata() {
263 let mut current = murk_with(&[("K", "v")]);
264 create_grant(
265 &mut current,
266 "g",
267 "age1a",
268 &["K".into()],
269 "age1owner",
270 Utc::now(),
271 Duration::hours(1),
272 )
273 .unwrap();
274 let removed = remove_grant(&mut current, "g").unwrap();
275 assert_eq!(removed.pubkey, "age1a");
276 assert!(current.grants.is_empty());
277 assert!(remove_grant(&mut current, "g").is_err());
278 }
279}