a73x

a0e2e424

add SSH authorized_keys parser

a73x   2026-03-30 18:40

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/src/server/main.rs b/src/server/main.rs
index 77871b0..46b49aa 100644
--- a/src/server/main.rs
+++ b/src/server/main.rs
@@ -1,5 +1,6 @@
mod config;
mod repos;
mod ssh;

fn main() {
    println!("git-collab-server: not yet implemented");
diff --git a/src/server/ssh/auth.rs b/src/server/ssh/auth.rs
new file mode 100644
index 0000000..be26001
--- /dev/null
+++ b/src/server/ssh/auth.rs
@@ -0,0 +1,82 @@
use std::path::Path;

#[derive(Debug, Clone)]
pub struct AuthorizedKey {
    pub key_type: String,
    pub key_data: String,
    pub comment: Option<String>,
}

pub fn parse_authorized_keys(content: &str) -> Vec<AuthorizedKey> {
    content
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                return None;
            }
            let mut parts = line.splitn(3, ' ');
            let key_type = parts.next()?.to_string();
            let key_data = parts.next()?.to_string();
            let comment = parts.next().map(|s| s.to_string());
            Some(AuthorizedKey { key_type, key_data, comment })
        })
        .collect()
}

pub fn load_authorized_keys(path: &Path) -> Result<Vec<AuthorizedKey>, std::io::Error> {
    let content = std::fs::read_to_string(path)?;
    Ok(parse_authorized_keys(&content))
}

pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool {
    keys.iter().any(|k| k.key_type == key_type && k.key_data == key_data)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_single_key() {
        let content = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
        assert_eq!(keys[0].key_type, "ssh-ed25519");
        assert_eq!(keys[0].key_data, "AAAAC3NzaC1lZDI1NTE5AAAAITest");
        assert_eq!(keys[0].comment.as_deref(), Some("user@host"));
    }

    #[test]
    fn parse_multiple_keys() {
        let content = "# alice\nssh-ed25519 AAAA1111 alice@example.com\n\n# bob\nssh-ed25519 AAAA2222 bob@work\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 2);
        assert_eq!(keys[0].key_data, "AAAA1111");
        assert_eq!(keys[1].key_data, "AAAA2222");
    }

    #[test]
    fn skip_comments_and_blanks() {
        let content = "# this is a comment\n\n   # indented comment\n\nssh-ed25519 AAAAkey user\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
    }

    #[test]
    fn key_without_comment() {
        let content = "ssh-ed25519 AAAAnocomment\n";
        let keys = parse_authorized_keys(content);
        assert_eq!(keys.len(), 1);
        assert!(keys[0].comment.is_none());
    }

    #[test]
    fn is_authorized_matches() {
        let keys = parse_authorized_keys("ssh-ed25519 AAAA1111 alice\nssh-ed25519 AAAA2222 bob\n");
        assert!(is_authorized(&keys, "ssh-ed25519", "AAAA1111"));
        assert!(is_authorized(&keys, "ssh-ed25519", "AAAA2222"));
        assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999"));
        assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111"));
    }
}
diff --git a/src/server/ssh/mod.rs b/src/server/ssh/mod.rs
new file mode 100644
index 0000000..0e4a05d
--- /dev/null
+++ b/src/server/ssh/mod.rs
@@ -0,0 +1 @@
pub mod auth;