1use mkit_attest::grant::text::encode_audiences;
10use mkit_attest::grant::{
11 Capabilities, EpochStatement, Grant, GrantError, MAX_AUDIENCES, MAX_REF_SCOPES, Namespace,
12 RefFlags, RefPattern, RefScopes, RepoScope, RepositoryIdentity, Visibility,
13 VisibilityStatement,
14};
15
16pub const MAX_TTL_SECS: u64 = 2_592_000;
18pub const DEFAULT_GRANT_TTL: &str = "7d";
20pub const DEFAULT_STATEMENT_TTL_MS: i64 = 10 * 60 * 1000;
24
25const STATEMENT_SLACK_MS: i64 = 2 * 60 * 1000;
27
28#[must_use]
33pub fn statement_lifetime_ms(wait: std::time::Duration) -> i64 {
34 let wait_ms = i64::try_from(wait.as_millis()).unwrap_or(i64::MAX);
35 wait_ms
36 .saturating_add(STATEMENT_SLACK_MS)
37 .clamp(DEFAULT_STATEMENT_TTL_MS, MAX_TTL_SECS.cast_signed() * 1000)
38}
39
40pub fn parse_ttl(text: &str) -> Result<i64, String> {
46 let text = text.trim();
47 let (digits, unit) = match text.char_indices().last() {
48 Some((i, 'd')) => (&text[..i], 86_400),
49 Some((i, 'h')) => (&text[..i], 3_600),
50 Some((i, 'm')) => (&text[..i], 60),
51 Some((i, 's')) => (&text[..i], 1),
52 Some(_) => (text, 1),
53 None => return Err("empty duration".to_owned()),
54 };
55 let value: u64 = digits
56 .parse()
57 .map_err(|_| format!("`{text}` is not a duration (use 30d, 12h, 90m or 3600s)"))?;
58 let seconds = value
59 .checked_mul(unit)
60 .filter(|s| *s > 0)
61 .ok_or_else(|| format!("`{text}` is not a positive duration"))?;
62 if seconds > MAX_TTL_SECS {
63 return Err(format!(
64 "`{text}` exceeds the 30-day maximum grant lifetime (SPEC-WRITE-GRANTS §1.1)"
65 ));
66 }
67 i64::try_from(seconds * 1000).map_err(|_| "duration out of range".to_owned())
68}
69
70pub fn canonical_capabilities(text: &str) -> Result<Capabilities, String> {
76 let (mut read, mut write) = (false, false);
77 for part in text.split(',') {
78 match part.trim() {
79 "read" => read = true,
80 "write" => write = true,
81 other => {
82 return Err(format!(
83 "capability `{other}` is not `read` or `write` (use --cap read, read,write or write)"
84 ));
85 }
86 }
87 }
88 Ok(match (read, write) {
89 (true, true) => Capabilities::ReadWrite,
90 (true, false) => Capabilities::Read,
91 _ => Capabilities::Write,
92 })
93}
94
95pub fn canonical_audiences(items: &[String]) -> Result<Vec<String>, String> {
100 let mut out: Vec<String> = items.iter().map(|a| a.trim().to_owned()).collect();
101 out.sort();
102 out.dedup();
103 if out.is_empty() || out.len() > MAX_AUDIENCES {
104 return Err(format!("expected 1 to {MAX_AUDIENCES} audiences"));
105 }
106 for audience in &out {
107 encode_audiences(std::slice::from_ref(audience))
108 .map_err(|e| format!("audience `{audience}`: {e}"))?;
109 }
110 Ok(out)
111}
112
113pub fn canonical_ref_scopes(items: &[String]) -> Result<RefScopes, String> {
120 let mut merged: Vec<(RefPattern, RefFlags)> = Vec::new();
121 for item in items {
122 let (pattern, flags) = item
123 .split_once('=')
124 .ok_or_else(|| format!("--refs `{item}` must be `pattern=flags` (flags from cufd)"))?;
125 let pattern = RefPattern::parse(pattern).map_err(|e| format!("--refs `{item}`: {e}"))?;
126 let mut set = RefFlags::EMPTY;
127 for c in flags.chars() {
128 set = set.union(match c {
129 'c' => RefFlags::CREATE,
130 'u' => RefFlags::UPDATE,
131 'f' => RefFlags::FORCE,
132 'd' => RefFlags::DELETE,
133 other => {
134 return Err(format!(
135 "--refs `{item}`: flag `{other}` is not one of c, u, f, d"
136 ));
137 }
138 });
139 }
140 if set.is_empty() {
141 return Err(format!("--refs `{item}`: no flags"));
142 }
143 match merged.iter_mut().find(|(p, _)| *p == pattern) {
144 Some((_, existing)) => *existing = existing.union(set),
145 None => merged.push((pattern, set)),
146 }
147 }
148 merged.sort_by_key(|(pattern, flags)| format!("{pattern}={flags}"));
149 if merged.len() > MAX_REF_SCOPES {
150 return Err(format!("at most {MAX_REF_SCOPES} ref scopes"));
151 }
152 RefScopes::new(merged).map_err(|e| format!("--refs: {e}"))
153}
154
155#[derive(Debug, Clone)]
157pub enum RepoSelector {
158 Name(String),
159 All,
160}
161
162#[derive(Debug, Clone)]
164pub struct GrantSpec {
165 pub repo: RepoSelector,
166 pub grantee: [u8; 32],
167 pub capabilities: Capabilities,
168 pub audiences: Vec<String>,
169 pub ref_scopes: Option<RefScopes>,
170 pub epoch: u64,
171 pub ttl_ms: i64,
172}
173
174fn nonce() -> Result<[u8; 32], String> {
175 let mut nonce = [0u8; 32];
176 getrandom::fill(&mut nonce).map_err(|e| format!("no entropy for the statement nonce: {e}"))?;
177 Ok(nonce)
178}
179
180pub fn check_ref_scopes(
185 capabilities: Capabilities,
186 ref_scopes: Option<&RefScopes>,
187) -> Result<(), String> {
188 match (capabilities, ref_scopes) {
189 (Capabilities::Read, Some(_)) => Err(
190 "a read-only grant takes no --refs (ref scopes apply to writes; SPEC-WRITE-GRANTS §3.3)"
191 .to_owned(),
192 ),
193 (Capabilities::ReadWrite | Capabilities::Write, None) => Err(
194 "a grant with write needs at least one --refs pattern=flags (for example --refs 'refs/heads/*=cuf')"
195 .to_owned(),
196 ),
197 _ => Ok(()),
198 }
199}
200
201pub fn build_grant(spec: &GrantSpec, namespace: &Namespace, now_ms: i64) -> Result<Grant, String> {
206 let scope = match &spec.repo {
207 RepoSelector::All => RepoScope::Namespace,
208 RepoSelector::Name(name) => RepoScope::Repository(
209 RepositoryIdentity::parse(&format!("{namespace}/{name}"))
210 .map_err(|e| format!("--repo `{name}`: {e}"))?,
211 ),
212 };
213 check_ref_scopes(spec.capabilities, spec.ref_scopes.as_ref())?;
214 Ok(Grant {
215 namespace: *namespace,
216 scope,
217 grantee: spec.grantee,
218 capabilities: spec.capabilities,
219 audiences: spec.audiences.clone(),
220 ref_scopes: spec.ref_scopes.clone(),
221 epoch: spec.epoch,
222 created_ms: now_ms,
223 expiry_ms: now_ms
224 .checked_add(spec.ttl_ms)
225 .ok_or("lifetime out of range")?,
226 nonce: nonce()?,
227 })
228}
229
230pub fn build_epoch(
235 namespace: &Namespace,
236 new_epoch: u64,
237 audiences: &[String],
238 now_ms: i64,
239 lifetime_ms: i64,
240) -> Result<EpochStatement, String> {
241 Ok(EpochStatement {
242 namespace: *namespace,
243 new_epoch,
244 audiences: audiences.to_vec(),
245 created_ms: now_ms,
246 expiry_ms: now_ms.saturating_add(lifetime_ms),
247 nonce: nonce()?,
248 })
249}
250
251pub fn build_visibility(
256 repository: &RepositoryIdentity,
257 visibility: Visibility,
258 audiences: &[String],
259 now_ms: i64,
260 lifetime_ms: i64,
261) -> Result<VisibilityStatement, String> {
262 Ok(VisibilityStatement {
263 repository: repository.clone(),
264 visibility,
265 audiences: audiences.to_vec(),
266 created_ms: now_ms,
267 expiry_ms: now_ms.saturating_add(lifetime_ms),
268 nonce: nonce()?,
269 })
270}
271
272#[must_use]
274pub fn statement_error(error: GrantError) -> String {
275 format!("invalid statement: {error}")
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn capabilities_are_canonicalized() {
284 for (input, want) in [
285 ("read", Capabilities::Read),
286 ("write", Capabilities::Write),
287 ("read,write", Capabilities::ReadWrite),
288 ("write,read", Capabilities::ReadWrite),
289 ("write, read", Capabilities::ReadWrite),
290 ("read,read", Capabilities::Read),
291 ] {
292 assert_eq!(canonical_capabilities(input).unwrap(), want, "{input}");
293 }
294 for bad in ["", "admin", "read;write", "read,"] {
295 assert!(canonical_capabilities(bad).is_err(), "{bad:?}");
296 }
297 assert_eq!(
298 canonical_capabilities("write,read").unwrap().token(),
299 "read,write"
300 );
301 }
302
303 #[test]
304 fn audiences_are_sorted_deduped_and_bounded() {
305 let got = canonical_audiences(&[
306 "https://b.example.com".to_owned(),
307 "https://a.example.com".to_owned(),
308 "https://b.example.com".to_owned(),
309 ])
310 .unwrap();
311 assert_eq!(got, ["https://a.example.com", "https://b.example.com"]);
312 assert!(canonical_audiences(&[]).is_err());
313 assert!(canonical_audiences(&["https://*.example.com".to_owned()]).is_err());
314 assert!(canonical_audiences(&["git.example.com".to_owned()]).is_err());
315 let nine: Vec<String> = (0..9)
316 .map(|i| format!("https://h{i}.example.com"))
317 .collect();
318 assert!(canonical_audiences(&nine).is_err());
319 }
320
321 #[test]
322 fn ref_scopes_are_sorted_merged_and_flag_ordered() {
323 let scopes = canonical_ref_scopes(&[
324 "refs/heads/wip/*=dfuc".to_owned(),
325 "refs/heads/main=u".to_owned(),
326 "refs/heads/main=c".to_owned(),
327 ])
328 .unwrap();
329 let text = scopes
330 .entries()
331 .iter()
332 .map(|(p, f)| format!("{p}={f}"))
333 .collect::<Vec<_>>()
334 .join(";");
335 assert_eq!(text, "refs/heads/main=cu;refs/heads/wip/*=cufd");
336 for bad in [
337 "refs/heads/main",
338 "refs/heads/main=x",
339 "refs/heads/main=",
340 "*=c",
341 ] {
342 assert!(canonical_ref_scopes(&[bad.to_owned()]).is_err(), "{bad}");
343 }
344 }
345
346 #[test]
347 fn statement_lifetime_covers_the_wait_and_stays_in_bounds() {
348 use std::time::Duration;
349 assert_eq!(
350 statement_lifetime_ms(Duration::from_secs(5)),
351 DEFAULT_STATEMENT_TTL_MS
352 );
353 assert_eq!(
354 statement_lifetime_ms(Duration::from_mins(5)),
355 DEFAULT_STATEMENT_TTL_MS
356 );
357 assert_eq!(
359 statement_lifetime_ms(Duration::from_mins(30)),
360 32 * 60 * 1000
361 );
362 assert_eq!(
363 statement_lifetime_ms(Duration::from_secs(u64::MAX / 4)),
364 2_592_000_000
365 );
366 }
367
368 #[test]
369 fn ttl_parses_units_and_enforces_the_bound() {
370 assert_eq!(parse_ttl("30d").unwrap(), 2_592_000_000);
371 assert_eq!(parse_ttl("12h").unwrap(), 43_200_000);
372 assert_eq!(parse_ttl("90m").unwrap(), 5_400_000);
373 assert_eq!(parse_ttl("3600s").unwrap(), 3_600_000);
374 assert_eq!(parse_ttl("60").unwrap(), 60_000);
375 assert!(parse_ttl("31d").is_err());
376 assert!(parse_ttl("0d").is_err());
377 assert!(parse_ttl("").is_err());
378 assert!(parse_ttl("abc").is_err());
379 }
380}