use std::net::SocketAddr; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::sync::Arc; use std::time::Duration; use db::channel::channels; use db::conn::Db; use db::conn::sea_orm::{ ActiveModelTrait, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, EntityTrait, Schema, Set, }; use db::repo::repos; use db::space::spaces; use db::user::user_ssh_keys; use db::user::users; use gitauth::GitAuthService; use gitssh::server::{generate_host_key, serve_on_listener}; use gitssh::{GitSshConfig, GitSshService}; use jwt::{JwtSettings, JwtState, SigningKeyCache}; use sha2::{Digest, Sha256}; use tempfile::TempDir; use time::OffsetDateTime; use tokio::sync::RwLock; use uuid::Uuid; fn now() -> OffsetDateTime { OffsetDateTime::now_utc() } fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); let digest = hasher.finalize(); let mut out = String::with_capacity(64); for b in digest { out.push_str(&format!("{b:02x}")); } out } async fn create_table(db: &DatabaseConnection) { let stmt = Schema::new(DatabaseBackend::Sqlite).create_table_from_entity(E::default()); db.execute(&stmt).await.expect("create table"); } async fn setup_db() -> DatabaseConnection { let db = Database::connect("sqlite::memory:") .await .expect("connect sqlite memory"); create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; create_table::(&db).await; db } async fn insert_user(db: &impl ConnectionTrait, uid: Uuid, username: &str) { users::ActiveModel { uid: Set(uid), username: Set(username.to_string()), lower_name: Set(username.to_lowercase()), display_name: Set(username.to_string()), avatar: Set(String::new()), is_active: Set(true), is_admin: Set(false), is_restricted: Set(false), prohibit_login: Set(false), visibility: Set(users::ProfileVisibility::Public), bot: Set(false), system: Set(false), totp_enabled: Set(false), version: Set(1), passwd_hash: Set(None), created_at: Set(now()), updated_at: Set(now()), ..Default::default() } .insert(db) .await .expect("insert user"); } async fn insert_space(db: &impl ConnectionTrait, id: Uuid, lower_name: &str, owner_id: Uuid) { spaces::ActiveModel { id: Set(id), lower_name: Set(lower_name.to_string()), name: Set(lower_name.to_string()), owner_id: Set(owner_id), public: Set(true), is_active: Set(true), allow_join: Set(true), allow_invite: Set(true), member_count: Set(0), allow_join_level: Set(spaces::AllowJoinLevel::None), member_must_enable_2fa: Set(false), created_at: Set(now()), updated_at: Set(now()), ..Default::default() } .insert(db) .await .expect("insert space"); } async fn insert_channel( db: &impl ConnectionTrait, id: Uuid, space_id: Uuid, name: &str, created_by: Uuid, ) { channels::ActiveModel { id: Set(id), space_id: Set(Some(space_id)), name: Set(name.to_string()), position: Set(0), is_archived: Set(false), created_at: Set(now()), updated_at: Set(now()), created_by: Set(created_by), ..Default::default() } .insert(db) .await .expect("insert channel"); } async fn insert_repo( db: &impl ConnectionTrait, id: Uuid, channel_id: Uuid, name: &str, is_private: bool, ) { repos::ActiveModel { id: Set(id), channel_id: Set(channel_id), lower_name: Set(name.to_lowercase()), name: Set(name.to_string()), default_branch: Set("main".to_string()), nums_watch: Set(0), nums_star: Set(0), nums_pr: Set(0), nums_commits: Set(0), nums_branches: Set(0), nums_tags: Set(0), enable_lfs: Set(true), is_private: Set(is_private), is_archived: Set(false), size: Set(0), git_size: Set(0), lfs_size: Set(0), allow_rebase: Set(true), allow_rebase_explicit: Set(true), allow_squash_merge: Set(true), allow_merge_commits: Set(true), created_at: Set(now()), object_format: Set("sha1".to_string()), ..Default::default() } .insert(db) .await .expect("insert repo"); } async fn insert_ssh_key(db: &impl ConnectionTrait, user_id: Uuid, content: &str) { let fingerprint = sha256_hex(content.as_bytes()); let am: user_ssh_keys::ActiveModel = user_ssh_keys::ActiveModel::builder() .set_id(Uuid::new_v4()) .set_user_id(user_id) .set_key_type(user_ssh_keys::SshKeyType::SshEd25519) .set_fingerprint(fingerprint) .set_name("test-key".to_string()) .set_content(content.to_string()) .set_created_at(now()) .set_updated_at(now()) .set_last_used_at(None::) .into(); am.insert(db).await.expect("insert ssh key"); } async fn git(dir: &Path, args: &[&str]) -> String { let out = tokio::process::Command::new("git") .args(args) .current_dir(dir) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_SSH_COMMAND", "") .output() .await .expect("run git"); assert!( out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).into_owned() } struct TestServer { _tmp: TempDir, addr: SocketAddr, port: u16, git_root: std::path::PathBuf, channel_id: Uuid, repo_id: Uuid, ssh_env: String, } async fn start_server() -> TestServer { let tmp = TempDir::new().expect("tempdir"); let root = tmp.path().to_path_buf(); let git_root = root.join("git"); std::fs::create_dir_all(&git_root).expect("git root"); let conn = setup_db().await; let db = Db::from_connection(conn.clone()); let alice = Uuid::new_v4(); let space_id = Uuid::new_v4(); let channel_id = Uuid::new_v4(); let repo_id = Uuid::new_v4(); insert_user(&conn, alice, "alice").await; insert_space(&conn, space_id, "acme", alice).await; insert_channel(&conn, channel_id, space_id, "dev", alice).await; insert_repo(&conn, repo_id, channel_id, "widget", false).await; let mut rng = rand::rng(); let client_key = russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519) .expect("client key"); let client_pub = client_key .public_key() .to_openssh() .expect("client pub openssh"); insert_ssh_key(&conn, alice, &client_pub).await; let client_key_path = root.join("id_ed25519"); let client_key_pem = client_key .to_openssh(ssh_key::LineEnding::LF) .expect("client key pem"); std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); std::fs::set_permissions(&client_key_path, std::fs::Permissions::from_mode(0o600)) .expect("chmod client key"); let bare = git_root.join(format!("{}/{}.git", channel_id, repo_id)); std::fs::create_dir_all(bare.parent().expect("bare parent")).expect("bare dirs"); git(&root, &["init", "--bare", bare.to_str().expect("bare str")]).await; let seed_dir = root.join("seed"); std::fs::create_dir_all(&seed_dir).expect("seed dir"); git( &root, &[ "init", "--initial-branch=main", seed_dir.to_str().expect("seed str"), ], ) .await; std::fs::write(seed_dir.join("README.md"), "seed\n").expect("write seed"); git(&seed_dir, &["add", "README.md"]).await; git( &seed_dir, &[ "-c", "user.name=Seed", "-c", "user.email=seed@example.com", "commit", "-m", "initial", ], ) .await; git( &seed_dir, &["remote", "add", "origin", bare.to_str().expect("bare str")], ) .await; git(&seed_dir, &["push", "-u", "origin", "main"]).await; let host_key = generate_host_key().expect("host key"); let host_key_path = root.join("host_key"); let host_key_pem = host_key .to_openssh(ssh_key::LineEnding::LF) .expect("host key pem"); std::fs::write(&host_key_path, host_key_pem.as_bytes()).expect("write host key"); let jwt = JwtState::new( conn.clone(), JwtSettings::default(), Arc::new(RwLock::new(SigningKeyCache::new())), ); let auth = GitAuthService::new(db, None, jwt); let config = GitSshConfig { git_storage_root: git_root.to_str().expect("git root str").to_string(), host_key_path: host_key_path.to_str().expect("host key str").to_string(), ..Default::default() }; let svc = GitSshService::new(auth, config); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("addr"); let port = addr.port(); tokio::spawn(async move { let _ = serve_on_listener(svc, listener).await; }); tokio::time::sleep(Duration::from_millis(100)).await; let ssh_env = format!( "ssh -i {} -p {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes", client_key_path.display(), port ); TestServer { _tmp: tmp, addr, port, git_root, channel_id, repo_id, ssh_env, } } async fn git_ssh(dir: &Path, ssh_env: &str, args: &[&str]) -> String { let out = tokio::process::Command::new("git") .args(args) .current_dir(dir) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_SSH_COMMAND", ssh_env) .output() .await .expect("run git"); assert!( out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).into_owned() } #[tokio::test] async fn ssh_clone_and_push_roundtrip() { let server = start_server().await; let url = format!("ssh://alice@127.0.0.1:{}/acme/widget.git", server.port); let clone_dir = server._tmp.path().join("clone"); git_ssh( server._tmp.path(), &server.ssh_env, &["clone", &url, clone_dir.to_str().expect("clone str")], ) .await; std::fs::write(clone_dir.join("hello.txt"), "hello ssh\n").expect("write file"); git(&clone_dir, &["add", "hello.txt"]).await; git( &clone_dir, &[ "-c", "user.name=Alice", "-c", "user.email=alice@example.com", "commit", "-m", "add hello", ], ) .await; git_ssh( &clone_dir, &server.ssh_env, &["push", "origin", "HEAD:main"], ) .await; let bare = server .git_root .join(format!("{}/{}.git", server.channel_id, server.repo_id)); let out = tokio::process::Command::new("git") .args(["log", "--oneline", "-1"]) .current_dir(&bare) .output() .await .expect("git log"); assert!( out.status.success(), "git log failed: {}", String::from_utf8_lossy(&out.stderr) ); let log = String::from_utf8_lossy(&out.stdout); assert!( log.contains("add hello"), "expected commit in bare repo, got: {log}" ); }