Skip to main content

mkit_cli/grants/
spec.rs

1//! Turning command-line spellings into the canonical statements of
2//! SPEC-WRITE-GRANTS §3, §5.1 and §9.1.
3//!
4//! The statement codecs in `mkit-attest` never sort or repair, so this
5//! module does the canonicalizing a person expects: `--cap write,read` becomes
6//! `read,write`, audiences and ref scopes are sorted and deduplicated, and ref
7//! flags are put in `cufd` order.
8
9use 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
16/// Longest lifetime `--ttl` accepts (§1.1): 30 days.
17pub const MAX_TTL_SECS: u64 = 2_592_000;
18/// Default `--ttl` of a grant.
19pub const DEFAULT_GRANT_TTL: &str = "7d";
20/// Default lifetime of an epoch or visibility statement. Short on purpose:
21/// the statement is signed for one command, and a shorter window narrows what
22/// a leaked statement can do.
23pub const DEFAULT_STATEMENT_TTL_MS: i64 = 10 * 60 * 1000;
24
25/// Slack on top of `--timeout` so the last re-send still lands before expiry.
26const STATEMENT_SLACK_MS: i64 = 2 * 60 * 1000;
27
28/// How long an epoch or visibility statement stays valid: the default, or,
29/// when the command will wait longer than that for the server, the wait plus a
30/// little slack, so a re-send late in a long `--timeout` is not rejected as
31/// expired. Never above the 30-day statement maximum.
32#[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
40/// `30d`, `12h`, `90m`, `3600s`, or bare seconds; at most 30 days. Returns
41/// milliseconds.
42///
43/// # Errors
44/// An unparsable, zero or too-long duration.
45pub 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
70/// Accept `read`, `write` or both in either order, and spell them
71/// canonically (§3.2): `read`, `read,write` or `write`.
72///
73/// # Errors
74/// Anything but a non-empty set of `read` and `write`.
75pub 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
95/// Sort and deduplicate audiences, and check each against §3.2.
96///
97/// # Errors
98/// An audience outside the auth v2 origin rules, or a count outside 1–8.
99pub 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
113/// Parse `pattern=flags` entries, put the flags in `cufd` order, merge
114/// entries with the same pattern (a union, which is what §8.1 computes anyway)
115/// and sort.
116///
117/// # Errors
118/// A malformed pattern or flag, or more than 16 distinct patterns.
119pub 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/// The repositories a new grant covers.
156#[derive(Debug, Clone)]
157pub enum RepoSelector {
158    Name(String),
159    All,
160}
161
162/// Everything `mkit grant create` collects before the owner is known.
163#[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
180/// §3.3: ref scopes go with write and only with write.
181///
182/// # Errors
183/// A read-only grant with `--refs`, or a write grant without.
184pub 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
201/// Build the grant for `namespace`, created at `now_ms`.
202///
203/// # Errors
204/// A scope, ref-scope or lifetime the statement rules refuse.
205pub 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
230/// Build an epoch statement raising `namespace` to `new_epoch`.
231///
232/// # Errors
233/// No entropy for the nonce.
234pub 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
251/// Build a visibility statement for `repository`.
252///
253/// # Errors
254/// No entropy for the nonce.
255pub 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/// A statement the codec refused, phrased for a person.
273#[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        // A 30 minute wait needs a statement that outlives it.
358        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}