1use std::fs;
42use std::path::Path;
43
44use ssh_key::PublicKey;
45
46use crate::AnvilError;
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, AnvilError> {
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 AnvilError::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, AnvilError> {
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]
158 pub fn find_principals_any_ns<'a>(&'a self, public_key: &PublicKey) -> Vec<&'a str> {
159 let mut out = Vec::new();
160 for entry in &self.entries {
161 if entry.public_key != *public_key {
162 continue;
163 }
164 for p in &entry.principals {
165 out.push(p.as_str());
166 }
167 }
168 out
169 }
170
171 #[must_use]
178 pub fn is_authorized(&self, identity: &str, public_key: &PublicKey, namespace: &str) -> bool {
179 for entry in &self.entries {
180 if entry.public_key != *public_key {
181 continue;
182 }
183 if let Some(ref allowed) = entry.namespaces {
184 if !allowed.iter().any(|ns| ns == namespace) {
185 continue;
186 }
187 }
188 if principals_match(&entry.principals, identity) {
189 return true;
190 }
191 }
192 false
193 }
194}
195
196fn parse_line(line: &str) -> Result<Entry, String> {
200 let mut rest = line;
201
202 let (principals_raw, after) = take_field(rest)?;
204 rest = after.trim_start();
205 let principals = split_principals(&principals_raw);
206 if principals.is_empty() {
207 return Err("empty principals list".to_owned());
208 }
209
210 let (maybe_options, after) = take_field(rest)?;
215 let (options_str, key_type, key_base64) = if is_ssh_key_algorithm(&maybe_options) {
216 let (kt, after2) = (maybe_options, after);
217 let (kb, _after3) = take_field(after2.trim_start())?;
218 (String::new(), kt, kb)
219 } else {
220 rest = after.trim_start();
221 let (kt, after2) = take_field(rest)?;
222 if !is_ssh_key_algorithm(&kt) {
223 return Err(format!("expected key algorithm, got {kt:?}"));
224 }
225 let (kb, _after3) = take_field(after2.trim_start())?;
226 (maybe_options, kt, kb)
227 };
228
229 let (namespaces, cert_authority) = parse_options(&options_str);
230
231 let openssh = format!("{key_type} {key_base64}");
233 let public_key =
234 PublicKey::from_openssh(&openssh).map_err(|e| format!("invalid public key: {e}"))?;
235
236 Ok(Entry {
237 principals,
238 namespaces,
239 cert_authority,
240 public_key,
241 })
242}
243
244fn take_field(input: &str) -> Result<(String, &str), String> {
246 let input = input.trim_start();
247 if input.is_empty() {
248 return Err("unexpected end of line".to_owned());
249 }
250 if let Some(stripped) = input.strip_prefix('"') {
251 let end = stripped
252 .find('"')
253 .ok_or_else(|| "unterminated quoted string".to_owned())?;
254 let field = stripped[..end].to_owned();
255 let remainder = &stripped[end + 1..];
256 Ok((field, remainder))
257 } else {
258 let end = input.find(char::is_whitespace).unwrap_or(input.len());
259 Ok((input[..end].to_owned(), &input[end..]))
260 }
261}
262
263fn split_principals(field: &str) -> Vec<String> {
265 field
266 .split(',')
267 .map(str::trim)
268 .filter(|s| !s.is_empty())
269 .map(std::borrow::ToOwned::to_owned)
270 .collect()
271}
272
273fn parse_options(options: &str) -> (Option<Vec<String>>, bool) {
279 if options.is_empty() {
280 return (None, false);
281 }
282 let mut namespaces = None;
283 let mut cert_authority = false;
284 for opt in split_options(options) {
285 if opt.eq_ignore_ascii_case("cert-authority") {
286 cert_authority = true;
287 } else if let Some(value) = opt.strip_prefix("namespaces=") {
288 let trimmed = value.trim_matches('"');
289 namespaces = Some(
290 trimmed
291 .split(',')
292 .map(str::trim)
293 .filter(|s| !s.is_empty())
294 .map(std::borrow::ToOwned::to_owned)
295 .collect(),
296 );
297 }
298 }
299 (namespaces, cert_authority)
300}
301
302fn split_options(input: &str) -> Vec<String> {
304 let mut out = Vec::new();
305 let mut current = String::new();
306 let mut in_quote = false;
307 for c in input.chars() {
308 match c {
309 '"' => {
310 in_quote = !in_quote;
311 current.push(c);
312 }
313 ',' if !in_quote => {
314 let s = current.trim().to_owned();
315 if !s.is_empty() {
316 out.push(s);
317 }
318 current.clear();
319 }
320 _ => current.push(c),
321 }
322 }
323 let s = current.trim().to_owned();
324 if !s.is_empty() {
325 out.push(s);
326 }
327 out
328}
329
330fn is_ssh_key_algorithm(s: &str) -> bool {
333 matches!(
334 s,
335 "ssh-ed25519"
336 | "ssh-rsa"
337 | "rsa-sha2-256"
338 | "rsa-sha2-512"
339 | "ecdsa-sha2-nistp256"
340 | "ecdsa-sha2-nistp384"
341 | "ecdsa-sha2-nistp521"
342 | "ssh-dss"
343 | "sk-ssh-ed25519@openssh.com"
344 | "sk-ecdsa-sha2-nistp256@openssh.com"
345 )
346}
347
348fn principals_match(patterns: &[String], identity: &str) -> bool {
351 let mut matched = false;
352 for p in patterns {
353 let (negated, pat) = p
354 .strip_prefix('!')
355 .map_or((false, p.as_str()), |rest| (true, rest));
356 if glob_match(pat, identity) {
357 if negated {
358 return false;
359 }
360 matched = true;
361 }
362 }
363 matched
364}
365
366fn glob_match(pattern: &str, text: &str) -> bool {
368 let p: Vec<char> = pattern.chars().collect();
369 let t: Vec<char> = text.chars().collect();
370 glob_match_inner(&p, 0, &t, 0)
371}
372
373fn glob_match_inner(p: &[char], mut pi: usize, t: &[char], mut ti: usize) -> bool {
374 while pi < p.len() {
375 match p[pi] {
376 '*' => {
377 if pi + 1 == p.len() {
378 return true;
379 }
380 for skip in ti..=t.len() {
381 if glob_match_inner(p, pi + 1, t, skip) {
382 return true;
383 }
384 }
385 return false;
386 }
387 '?' => {
388 if ti >= t.len() {
389 return false;
390 }
391 pi += 1;
392 ti += 1;
393 }
394 c => {
395 if ti >= t.len() || t[ti] != c {
396 return false;
397 }
398 pi += 1;
399 ti += 1;
400 }
401 }
402 }
403 ti == t.len()
404}
405
406#[cfg(test)]
409mod tests {
410 use super::*;
411
412 const SAMPLE_ED25519: &str =
413 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEr3gQn+Fg1J1K5HT+0n2N1iA3Gn+Yx3hQJ3z4PxZQ7J tim@example.com";
414
415 #[test]
416 fn parse_single_entry() {
417 let input = format!("tim@example.com {SAMPLE_ED25519}");
418 let signers = AllowedSigners::parse(&input).unwrap();
419 assert_eq!(signers.len(), 1);
420 assert_eq!(signers.entries()[0].principals, vec!["tim@example.com"]);
421 assert!(signers.entries()[0].namespaces.is_none());
422 }
423
424 #[test]
425 fn parse_skips_blanks_and_comments() {
426 let input =
427 format!("\n# top comment\n\n # indented comment\ntim@example.com {SAMPLE_ED25519}\n");
428 let signers = AllowedSigners::parse(&input).unwrap();
429 assert_eq!(signers.len(), 1);
430 }
431
432 #[test]
433 fn parse_namespaces_option() {
434 let input = format!("tim@example.com namespaces=\"git,file\" {SAMPLE_ED25519}");
435 let signers = AllowedSigners::parse(&input).unwrap();
436 let ns = signers.entries()[0].namespaces.as_ref().unwrap();
437 assert_eq!(ns, &vec!["git".to_owned(), "file".to_owned()]);
438 }
439
440 #[test]
441 fn parse_multiple_principals_and_quoted() {
442 let input = format!("\"alice@example.com,bob@example.com\" {SAMPLE_ED25519}");
443 let signers = AllowedSigners::parse(&input).unwrap();
444 assert_eq!(
445 signers.entries()[0].principals,
446 vec!["alice@example.com", "bob@example.com"]
447 );
448 }
449
450 #[test]
451 fn parse_cert_authority() {
452 let input = format!("*@example.com cert-authority {SAMPLE_ED25519}");
453 let signers = AllowedSigners::parse(&input).unwrap();
454 assert!(signers.entries()[0].cert_authority);
455 }
456
457 #[test]
458 fn glob_matches_wildcard() {
459 assert!(glob_match("*@example.com", "tim@example.com"));
460 assert!(!glob_match("*@example.com", "tim@other.org"));
461 assert!(glob_match("*", ""));
462 assert!(glob_match("a?c", "abc"));
463 assert!(!glob_match("a?c", "ac"));
464 }
465
466 #[test]
467 fn is_authorized_respects_negation() {
468 let input = format!("*@example.com,!evil@example.com {SAMPLE_ED25519}");
469 let signers = AllowedSigners::parse(&input).unwrap();
470 let key = &signers.entries()[0].public_key;
471 assert!(signers.is_authorized("tim@example.com", key, "git"));
472 assert!(!signers.is_authorized("evil@example.com", key, "git"));
473 }
474
475 #[test]
476 fn is_authorized_respects_namespace_restriction() {
477 let input = format!("tim@example.com namespaces=\"git\" {SAMPLE_ED25519}");
478 let signers = AllowedSigners::parse(&input).unwrap();
479 let key = &signers.entries()[0].public_key;
480 assert!(signers.is_authorized("tim@example.com", key, "git"));
481 assert!(!signers.is_authorized("tim@example.com", key, "file"));
482 }
483
484 #[test]
485 fn rejects_missing_key() {
486 let err = AllowedSigners::parse("tim@example.com\n").unwrap_err();
487 assert!(err.to_string().contains("line 1"));
488 }
489}