Skip to main content

mkit_attest/grant/
visibility.rs

1//! The `mkit-repo-visibility:v1` statement (SPEC-WRITE-GRANTS §9.1).
2
3use mkit_core::repo_identity::RepositoryIdentity;
4
5use super::text::{
6    audiences, check_lifetime, decimal_millis, encode_audiences, encode_hex32, encode_millis,
7    hex32, join_fields, split_fields,
8};
9use super::{DOMAIN_VISIBILITY, EPOCH_STATEMENT_MAX_LIFETIME_MS, GrantError};
10
11/// Field count of a visibility statement.
12const VISIBILITY_FIELDS: usize = 7;
13
14/// A repository's visibility (§9.1).
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum Visibility {
17    /// `public`: readable by every caller.
18    Public,
19    /// `private`: readable only through §6 with the `read` capability.
20    Private,
21}
22
23impl Visibility {
24    /// The canonical token.
25    #[must_use]
26    pub fn token(self) -> &'static str {
27        match self {
28            Self::Public => "public",
29            Self::Private => "private",
30        }
31    }
32
33    /// Parse the canonical token.
34    ///
35    /// # Errors
36    /// `Visibility` for anything but `public` or `private`.
37    pub fn parse(s: &str) -> Result<Self, GrantError> {
38        match s {
39            "public" => Ok(Self::Public),
40            "private" => Ok(Self::Private),
41            _ => Err(GrantError::Visibility),
42        }
43    }
44}
45
46/// A parsed `mkit-repo-visibility:v1` statement: the owner sets a
47/// repository's visibility (§9.1).
48///
49/// [`VisibilityStatement::parse`] accepts exactly the canonical encoding and
50/// [`VisibilityStatement::encode`] reproduces it.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct VisibilityStatement {
53    /// A full identity `<namespace>/<name>` (§7.4); never a bare name.
54    pub repository: RepositoryIdentity,
55    /// The visibility to store.
56    pub visibility: Visibility,
57    /// 1 to 8 canonical origins in ascending byte order.
58    pub audiences: Vec<String>,
59    /// Creation time, epoch milliseconds. The deployment accepts only a
60    /// `created` greater than the last accepted one for the repository.
61    pub created_ms: i64,
62    /// Expiry, epoch milliseconds: `created < expiry <= created +
63    /// EPOCH_STATEMENT_MAX_LIFETIME_MS`.
64    pub expiry_ms: i64,
65    /// 32 bytes of fresh randomness.
66    pub nonce: [u8; 32],
67}
68
69impl VisibilityStatement {
70    /// Parse a statement, enforcing the §3.1 rules and the §9.1 fields. Never
71    /// repairs.
72    ///
73    /// # Errors
74    /// The [`GrantError`] of the first failed rule; `Repository` for a bare
75    /// name or an identity outside §7.4.
76    pub fn parse(bytes: &[u8]) -> Result<Self, GrantError> {
77        let f = split_fields(bytes, VISIBILITY_FIELDS)?;
78        if f[0] != DOMAIN_VISIBILITY {
79            return Err(GrantError::Domain);
80        }
81        // `parse` requires `<namespace>/<name>`: a bare name fails here.
82        let repository = RepositoryIdentity::parse(f[1]).map_err(|_| GrantError::Repository)?;
83        let visibility = Visibility::parse(f[2])?;
84        let audiences = audiences(f[3])?;
85        let created_ms = decimal_millis(f[4])?;
86        let expiry_ms = decimal_millis(f[5])?;
87        let nonce = hex32(f[6])?;
88        check_lifetime(created_ms, expiry_ms, EPOCH_STATEMENT_MAX_LIFETIME_MS)?;
89        Ok(Self {
90            repository,
91            visibility,
92            audiences,
93            created_ms,
94            expiry_ms,
95            nonce,
96        })
97    }
98
99    /// Encode the canonical statement. Validates every rule
100    /// [`VisibilityStatement::parse`] enforces and never sorts or repairs.
101    ///
102    /// # Errors
103    /// The [`GrantError`] of the first failed rule.
104    pub fn encode(&self) -> Result<Vec<u8>, GrantError> {
105        if self.repository.namespace().is_none() {
106            return Err(GrantError::Repository);
107        }
108        let audiences = encode_audiences(&self.audiences)?;
109        check_lifetime(
110            self.created_ms,
111            self.expiry_ms,
112            EPOCH_STATEMENT_MAX_LIFETIME_MS,
113        )?;
114        join_fields(&[
115            DOMAIN_VISIBILITY,
116            &self.repository.to_string(),
117            self.visibility.token(),
118            &audiences,
119            &encode_millis(self.created_ms)?,
120            &encode_millis(self.expiry_ms)?,
121            &encode_hex32(&self.nonce),
122        ])
123    }
124
125    /// The statement id: the BLAKE3 of the canonical statement.
126    ///
127    /// # Errors
128    /// As [`VisibilityStatement::encode`].
129    pub fn id(&self) -> Result<[u8; 32], GrantError> {
130        Ok(mkit_core::hash::hash(&self.encode()?))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    const REPO: &str = "0x8ba1f109551bd432803012645ac136ddd64dba72/website";
139    const NONCE: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
140
141    fn fields() -> Vec<String> {
142        [
143            DOMAIN_VISIBILITY,
144            REPO,
145            "private",
146            "https://git.example.com",
147            "1790000000000",
148            "1790086400000",
149            NONCE,
150        ]
151        .map(str::to_owned)
152        .to_vec()
153    }
154
155    fn with(i: usize, value: &str) -> Vec<u8> {
156        let mut f = fields();
157        f[i] = value.to_owned();
158        f.join("\n").into_bytes()
159    }
160
161    fn rejects(bytes: &[u8], err: GrantError) {
162        assert_eq!(
163            VisibilityStatement::parse(bytes),
164            Err(err),
165            "{}",
166            String::from_utf8_lossy(bytes)
167        );
168    }
169
170    #[test]
171    fn visibility_statement_roundtrips() {
172        for token in ["public", "private"] {
173            let bytes = with(2, token);
174            let s = VisibilityStatement::parse(&bytes).unwrap();
175            assert_eq!(s.visibility.token(), token);
176            assert_eq!(s.repository.to_string(), REPO);
177            assert_eq!(s.encode().unwrap(), bytes);
178            assert_eq!(s.id().unwrap(), mkit_core::hash::hash(&bytes));
179        }
180    }
181
182    #[test]
183    fn visibility_statement_rejects_each_rule() {
184        let f = fields();
185        rejects(f[..6].join("\n").as_bytes(), GrantError::FieldCount);
186        rejects(
187            format!("{}\n", f.join("\n")).as_bytes(),
188            GrantError::FinalLineFeed,
189        );
190        rejects(f.join("\r\n").as_bytes(), GrantError::CarriageReturn);
191        rejects(&with(0, "mkit-write-epoch:v1"), GrantError::Domain);
192        rejects(&with(0, "mkit-repo-visibility:v2"), GrantError::Domain);
193        rejects(&with(1, "website"), GrantError::Repository);
194        rejects(
195            &with(1, &REPO.replace("website", "Website")),
196            GrantError::Repository,
197        );
198        rejects(
199            &with(1, &REPO.replace("website", "*")),
200            GrantError::Repository,
201        );
202        rejects(&with(1, &REPO.replace("0x", "0X")), GrantError::Repository);
203        for bad in ["Public", "internal", "public,private", "private "] {
204            let err = if bad.ends_with(' ') {
205                GrantError::ByteOutOfRange
206            } else {
207                GrantError::Visibility
208            };
209            rejects(&with(2, bad), err);
210        }
211        rejects(&with(3, "*"), GrantError::AudienceWildcard);
212        rejects(
213            &with(3, "https://git.example.com:443"),
214            GrantError::Audience,
215        );
216        rejects(&with(4, "01"), GrantError::Decimal);
217        rejects(&with(6, &NONCE.to_uppercase()), GrantError::Hex);
218        rejects(&with(5, "1789999999999"), GrantError::ExpiryNotAfterCreated);
219        let over = (1_790_000_000_000_i64 + EPOCH_STATEMENT_MAX_LIFETIME_MS + 1).to_string();
220        rejects(&with(5, &over), GrantError::LifetimeTooLong);
221    }
222
223    #[test]
224    fn visibility_statement_encode_validates() {
225        let good = VisibilityStatement::parse(&fields().join("\n").into_bytes()).unwrap();
226        let mut s = good.clone();
227        s.repository = RepositoryIdentity::parse_bare_allowed("website").unwrap();
228        assert_eq!(s.encode(), Err(GrantError::Repository));
229        let mut s = good;
230        s.audiences.reverse();
231        s.audiences.push("https://a.example".into());
232        assert_eq!(s.encode(), Err(GrantError::AudiencesUnordered));
233    }
234}