Skip to content
Clone
a1591c8
438 lines 12.6 KB Rust
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::channel_members;
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<E: EntityTrait>(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::<users::Entity>(&db).await;
create_table::<spaces::Entity>(&db).await;
create_table::<channels::Entity>(&db).await;
create_table::<channel_members::Entity>(&db).await;
create_table::<db::space::space_members::Entity>(&db).await;
create_table::<db::space::space_roles::Entity>(&db).await;
create_table::<db::space::space_member_roles::Entity>(&db).await;
create_table::<db::space::space_role_permissions::Entity>(&db).await;
create_table::<db::repo::repo_collaborations::Entity>(&db).await;
create_table::<repos::Entity>(&db).await;
create_table::<user_ssh_keys::Entity>(&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_channel_member(
db: &impl ConnectionTrait,
channel_id: Uuid,
user_id: Uuid,
role: channel_members::ChannelMemberRole,
) {
channel_members::ActiveModel {
channel_id: Set(channel_id),
user_id: Set(user_id),
role: Set(role),
muted: Set(false),
joined_at: Set(now()),
..Default::default()
}
.insert(db)
.await
.expect("insert channel member");
}
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::<time::OffsetDateTime>)
.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;
insert_channel_member(
&conn,
channel_id,
alice,
channel_members::ChannelMemberRole::Owner,
)
.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}"
);
}