1use 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
11const VISIBILITY_FIELDS: usize = 7;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum Visibility {
17 Public,
19 Private,
21}
22
23impl Visibility {
24 #[must_use]
26 pub fn token(self) -> &'static str {
27 match self {
28 Self::Public => "public",
29 Self::Private => "private",
30 }
31 }
32
33 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#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct VisibilityStatement {
53 pub repository: RepositoryIdentity,
55 pub visibility: Visibility,
57 pub audiences: Vec<String>,
59 pub created_ms: i64,
62 pub expiry_ms: i64,
65 pub nonce: [u8; 32],
67}
68
69impl VisibilityStatement {
70 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 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 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 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}