1use std::fs;
42use std::path::Path;
43
44use ssh_key::PublicKey;
45
46use crate::GitwayError;
47
48#[derive(Debug, Clone)]
52pub struct Entry {
53 pub principals: Vec<String>,
58 pub namespaces: Option<Vec<String>>,
63 pub cert_authority: bool,
65 pub public_key: PublicKey,
67}
68
69#[derive(Debug, Clone)]
71pub struct AllowedSigners {
72 entries: Vec<Entry>,
73}
74
75impl AllowedSigners {
76 pub fn parse(input: &str) -> Result<Self, GitwayError> {
82 let mut entries = Vec::new();
83 for (lineno, raw) in input.lines().enumerate() {
84 let line = raw.trim();
85 if line.is_empty() || line.starts_with('#') {
86 continue;
87 }
88 let entry = parse_line(line).map_err(|msg| {
89 GitwayError::invalid_config(format!("allowed_signers line {}: {msg}", lineno + 1))
90 })?;
91 entries.push(entry);
92 }
93 Ok(Self { entries })
94 }
95
96 pub fn load(path: &Path) -> Result<Self, GitwayError> {
103 let contents = fs::read_to_string(path)?;
104 Self::parse(&contents)
105 }
106
107 #[must_use]
109 pub fn len(&self) -> usize {
110 self.entries.len()
111 }
112
113 #[must_use]
115 pub fn is_empty(&self) -> bool {
116 self.entries.is_empty()
117 }
118
119 #[must_use]
121 pub fn entries(&self) -> &[Entry] {
122 &self.entries
123 }
124
125 #[must_use]
131 pub fn find_principals<'a>(&'a self, public_key: &PublicKey, namespace: &str) -> Vec<&'a str> {
132 let mut out = Vec::new();
133 for entry in &self.entries {
134 if entry.public_key != *public_key {
135 continue;
136 }
137 if let Some(ref allowed) = entry.namespaces {
138 if !allowed.iter().any(|ns| ns == namespace) {
139 continue;
140 }
141 }
142 for p in &entry.principals {
143 out.push(p.as_str());
144 }
145 }
146 out
147 }
148
149 #[must_use]
156 pub fn is_authorized(&self, identity: &str, public_key: &PublicKey, namespace: &str) -> bool {
157 for entry in &self.entries {
158 if entry.public_key != *public_key {
159 continue;
160 }
161 if let Some(ref allowed) = entry.namespaces {
162 if !allowed.iter().any(|ns| ns == namespace) {
163 continue;
164 }
165 }
166 if principals_match(&entry.principals, identity) {
167 return true;
168 }
169 }
170 false
171 }
172}
173
174fn parse_line(line: &str) -> Result<Entry, String> {
178 let mut rest = line;
179
180 let (principals_raw, after) = take_field(rest)?;
182 rest = after.trim_start();
183 let principals = split_principals(&principals_raw);
184 if principals.is_empty() {
185 return Err("empty principals list".to_owned());
186 }
187
188 let (maybe_options, after) = take_field(rest)?;
193 let (options_str, key_type, key_base64) = if is_ssh_key_algorithm(&maybe_options) {
194 let (kt, after2) = (maybe_options, after);
195 let (kb, _after3) = take_field(after2.trim_start())?;
196 (String::new(), kt, kb)
197 } else {
198 rest = after.trim_start();
199 let (kt, after2) = take_field(rest)?;
200 if !is_ssh_key_algorithm(&kt) {
201 return Err(format!("expected key algorithm, got {kt:?}"));
202 }
203 let (kb, _after3) = take_field(after2.trim_start())?;
204 (maybe_options, kt, kb)
205 };
206
207 let (namespaces, cert_authority) = parse_options(&options_str);
208
209 let openssh = format!("{key_type} {key_base64}");
211 let public_key =
212 PublicKey::from_openssh(&openssh).map_err(|e| format!("invalid public key: {e}"))?;
213
214 Ok(Entry {
215 principals,
216 namespaces,
217 cert_authority,
218 public_key,
219 })
220}
221
222fn take_field(input: &str) -> Result<(String, &str), String> {
224 let input = input.trim_start();
225 if input.is_empty() {
226 return Err("unexpected end of line".to_owned());
227 }
228 if let Some(stripped) = input.strip_prefix('"') {
229 let end = stripped
230 .find('"')
231 .ok_or_else(|| "unterminated quoted string".to_owned())?;
232 let field = stripped[..end].to_owned();
233 let remainder = &stripped[end + 1..];
234 Ok((field, remainder))
235 } else {
236 let end = input.find(char::is_whitespace).unwrap_or(input.len());
237 Ok((input[..end].to_owned(), &input[end..]))
238 }
239}
240
241fn split_principals(field: &str) -> Vec<String> {
243 field
244 .split(',')
245 .map(str::trim)
246 .filter(|s| !s.is_empty())
247 .map(std::borrow::ToOwned::to_owned)
248 .collect()
249}
250
251fn parse_options(options: &str) -> (Option<Vec<String>>, bool) {
257 if options.is_empty() {
258 return (None, false);
259 }
260 let mut namespaces = None;
261 let mut cert_authority = false;
262 for opt in split_options(options) {
263 if opt.eq_ignore_ascii_case("cert-authority") {
264 cert_authority = true;
265 } else if let Some(value) = opt.strip_prefix("namespaces=") {
266 let trimmed = value.trim_matches('"');
267 namespaces = Some(
268 trimmed
269 .split(',')
270 .map(str::trim)
271 .filter(|s| !s.is_empty())
272 .map(std::borrow::ToOwned::to_owned)
273 .collect(),
274 );
275 }
276 }
277 (namespaces, cert_authority)
278}
279
280fn split_options(input: &str) -> Vec<String> {
282 let mut out = Vec::new();
283 let mut current = String::new();
284 let mut in_quote = false;
285 for c in input.chars() {
286 match c {
287 '"' => {
288 in_quote = !in_quote;
289 current.push(c);
290 }
291 ',' if !in_quote => {
292 let s = current.trim().to_owned();
293 if !s.is_empty() {
294 out.push(s);
295 }
296 current.clear();
297 }
298 _ => current.push(c),
299 }
300 }
301 let s = current.trim().to_owned();
302 if !s.is_empty() {
303 out.push(s);
304 }
305 out
306}
307
308fn is_ssh_key_algorithm(s: &str) -> bool {
311 matches!(
312 s,
313 "ssh-ed25519"
314 | "ssh-rsa"
315 | "rsa-sha2-256"
316 | "rsa-sha2-512"
317 | "ecdsa-sha2-nistp256"
318 | "ecdsa-sha2-nistp384"
319 | "ecdsa-sha2-nistp521"
320 | "ssh-dss"
321 | "sk-ssh-ed25519@openssh.com"
322 | "sk-ecdsa-sha2-nistp256@openssh.com"
323 )
324}
325
326fn principals_match(patterns: &[String], identity: &str) -> bool {
329 let mut matched = false;
330 for p in patterns {
331 let (negated, pat) = p
332 .strip_prefix('!')
333 .map_or((false, p.as_str()), |rest| (true, rest));
334 if glob_match(pat, identity) {
335 if negated {
336 return false;
337 }
338 matched = true;
339 }
340 }
341 matched
342}
343
344fn glob_match(pattern: &str, text: &str) -> bool {
346 let p: Vec<char> = pattern.chars().collect();
347 let t: Vec<char> = text.chars().collect();
348 glob_match_inner(&p, 0, &t, 0)
349}
350
351fn glob_match_inner(p: &[char], mut pi: usize, t: &[char], mut ti: usize) -> bool {
352 while pi < p.len() {
353 match p[pi] {
354 '*' => {
355 if pi + 1 == p.len() {
356 return true;
357 }
358 for skip in ti..=t.len() {
359 if glob_match_inner(p, pi + 1, t, skip) {
360 return true;
361 }
362 }
363 return false;
364 }
365 '?' => {
366 if ti >= t.len() {
367 return false;
368 }
369 pi += 1;
370 ti += 1;
371 }
372 c => {
373 if ti >= t.len() || t[ti] != c {
374 return false;
375 }
376 pi += 1;
377 ti += 1;
378 }
379 }
380 }
381 ti == t.len()
382}
383
384#[cfg(test)]
387mod tests {
388 use super::*;
389
390 const SAMPLE_ED25519: &str =
391 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEr3gQn+Fg1J1K5HT+0n2N1iA3Gn+Yx3hQJ3z4PxZQ7J tim@example.com";
392
393 #[test]
394 fn parse_single_entry() {
395 let input = format!("tim@example.com {SAMPLE_ED25519}");
396 let signers = AllowedSigners::parse(&input).unwrap();
397 assert_eq!(signers.len(), 1);
398 assert_eq!(signers.entries()[0].principals, vec!["tim@example.com"]);
399 assert!(signers.entries()[0].namespaces.is_none());
400 }
401
402 #[test]
403 fn parse_skips_blanks_and_comments() {
404 let input =
405 format!("\n# top comment\n\n # indented comment\ntim@example.com {SAMPLE_ED25519}\n");
406 let signers = AllowedSigners::parse(&input).unwrap();
407 assert_eq!(signers.len(), 1);
408 }
409
410 #[test]
411 fn parse_namespaces_option() {
412 let input = format!("tim@example.com namespaces=\"git,file\" {SAMPLE_ED25519}");
413 let signers = AllowedSigners::parse(&input).unwrap();
414 let ns = signers.entries()[0].namespaces.as_ref().unwrap();
415 assert_eq!(ns, &vec!["git".to_owned(), "file".to_owned()]);
416 }
417
418 #[test]
419 fn parse_multiple_principals_and_quoted() {
420 let input = format!("\"alice@example.com,bob@example.com\" {SAMPLE_ED25519}");
421 let signers = AllowedSigners::parse(&input).unwrap();
422 assert_eq!(
423 signers.entries()[0].principals,
424 vec!["alice@example.com", "bob@example.com"]
425 );
426 }
427
428 #[test]
429 fn parse_cert_authority() {
430 let input = format!("*@example.com cert-authority {SAMPLE_ED25519}");
431 let signers = AllowedSigners::parse(&input).unwrap();
432 assert!(signers.entries()[0].cert_authority);
433 }
434
435 #[test]
436 fn glob_matches_wildcard() {
437 assert!(glob_match("*@example.com", "tim@example.com"));
438 assert!(!glob_match("*@example.com", "tim@other.org"));
439 assert!(glob_match("*", ""));
440 assert!(glob_match("a?c", "abc"));
441 assert!(!glob_match("a?c", "ac"));
442 }
443
444 #[test]
445 fn is_authorized_respects_negation() {
446 let input = format!("*@example.com,!evil@example.com {SAMPLE_ED25519}");
447 let signers = AllowedSigners::parse(&input).unwrap();
448 let key = &signers.entries()[0].public_key;
449 assert!(signers.is_authorized("tim@example.com", key, "git"));
450 assert!(!signers.is_authorized("evil@example.com", key, "git"));
451 }
452
453 #[test]
454 fn is_authorized_respects_namespace_restriction() {
455 let input = format!("tim@example.com namespaces=\"git\" {SAMPLE_ED25519}");
456 let signers = AllowedSigners::parse(&input).unwrap();
457 let key = &signers.entries()[0].public_key;
458 assert!(signers.is_authorized("tim@example.com", key, "git"));
459 assert!(!signers.is_authorized("tim@example.com", key, "file"));
460 }
461
462 #[test]
463 fn rejects_missing_key() {
464 let err = AllowedSigners::parse("tim@example.com\n").unwrap_err();
465 assert!(err.to_string().contains("line 1"));
466 }
467}