use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Configuration for a registered GitHub App (user-facing input). #[derive(Debug, Clone)] pub struct AppOptions { pub app_id: String, pub slug: String, pub owner_login: String, pub public: bool, pub private_key_pem: String, pub webhook_secret: Option, } /// Internal enriched app config with derived public key. #[derive(Debug, Clone)] pub struct RegisteredApp { pub config: AppOptions, /// Derived from `private_key_pem` during `register_app`. Used for JWT verification. pub public_key_pem: String, } /// An installation of a GitHub App on a specific owner. #[derive(Debug, Clone)] pub struct Installation { pub id: u64, pub app_id: String, pub owner: String, pub repositories: Vec, pub suspended: bool, } /// A repository in the fake. #[derive(Debug, Clone)] pub struct Repository { pub owner: String, pub name: String, pub branches: Vec, pub default_branch: String, pub private: bool, pub git_dir: Option, } /// A pull request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullRequest { pub number: u64, pub node_id: String, pub title: String, pub body: String, pub state: String, pub draft: bool, pub mergeable: bool, pub additions: u64, pub deletions: u64, pub changed_files: u64, pub html_url: String, pub user_login: String, pub head_ref: String, pub base_ref: String, pub created_at: String, pub updated_at: String, pub auto_merge: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutoMerge { pub enabled_at: String, pub merge_method: String, } /// A GitHub Projects V2 project. #[derive(Debug, Clone)] pub struct Project { pub node_id: String, pub number: u64, pub owner: String, pub owner_type: OwnerType, pub status_field_id: String, pub status_options: Vec, pub items: Vec, } #[derive(Debug, Clone, PartialEq)] pub enum OwnerType { Organization, User, } #[derive(Debug, Clone)] pub struct StatusOption { pub id: String, pub name: String, } #[derive(Debug, Clone)] pub struct ProjectItem { pub id: String, pub status: String, pub content: IssueContent, } #[derive(Debug, Clone)] pub struct IssueContent { pub id: String, pub number: u64, pub title: String, pub body: String, pub url: String, pub created_at: String, pub updated_at: String, pub assignee_ids: Vec, pub labels: Vec, } /// A release. #[derive(Debug, Clone)] pub struct Release { pub tag_name: String, } /// An app manifest conversion record. #[derive(Debug, Clone)] pub struct ManifestConversion { pub code: String, pub app_id: i64, pub slug: String, pub client_id: String, pub client_secret: String, pub webhook_secret: Option, pub pem: String, } /// A comment on an issue. #[derive(Debug, Clone)] pub struct Comment { pub issue_node_id: String, pub body: String, } /// Stores webhook configuration. #[derive(Debug, Clone, Default)] pub struct WebhookOptions { pub url: Option, pub content_type: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum PermissionLevel { #[default] None, Read, Write, } impl PermissionLevel { fn from_json(value: Option<&serde_json::Value>) -> Self { match value.and_then(|value| value.as_str()) { Some("read") => Self::Read, Some("write") => Self::Write, _ => Self::None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenPermission { Contents, PullRequests, Issues, OrganizationProjects, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TokenPermissions { pub contents: PermissionLevel, pub pull_requests: PermissionLevel, pub issues: PermissionLevel, pub organization_projects: PermissionLevel, } impl TokenPermissions { pub fn from_json(value: &serde_json::Value) -> Self { Self { contents: PermissionLevel::from_json(value.get("contents")), pull_requests: PermissionLevel::from_json(value.get("pull_requests")), issues: PermissionLevel::from_json(value.get("issues")), organization_projects: PermissionLevel::from_json(value.get("organization_projects")), } } pub fn level(&self, permission: TokenPermission) -> PermissionLevel { match permission { TokenPermission::Contents => self.contents, TokenPermission::PullRequests => self.pull_requests, TokenPermission::Issues => self.issues, TokenPermission::OrganizationProjects => self.organization_projects, } } } /// Info about an active installation access token. #[derive(Debug, Clone)] pub struct TokenInfo { pub app_id: String, pub installation_id: u64, pub repositories: Vec, pub permissions: serde_json::Value, } impl TokenInfo { pub fn allows_repo(&self, repo: &str) -> bool { self.repositories.iter().any(|allowed| allowed == repo) } pub fn parsed_permissions(&self) -> TokenPermissions { TokenPermissions::from_json(&self.permissions) } pub fn allows(&self, permission: TokenPermission, required: PermissionLevel) -> bool { self.parsed_permissions().level(permission) >= required } } /// Central in-memory state for the fake GitHub server. #[derive(Debug, Clone)] pub struct AppState { pub apps: HashMap, pub installations: Vec, pub repositories: Vec, pub pull_requests: HashMap<(String, String), Vec>, pub active_tokens: HashMap, pub projects: Vec, pub releases: HashMap<(String, String), Release>, pub manifest_conversions: HashMap, pub comments: Vec, pub webhook_config: WebhookOptions, pub next_installation_id: u64, pub next_pr_number: u64, pub viewer_id: String, } /// Derive the RSA public key PEM from a private key PEM using the openssl CLI. /// /// Panics if openssl is not available or the key is invalid. This is acceptable /// because the fake server is test infrastructure and openssl is already required /// by the test helpers that generate key pairs. pub fn derive_public_key_pem(private_key_pem: &str) -> String { use std::io::Write; use std::process::{Command, Stdio}; let mut child = Command::new("openssl") .args(["rsa", "-pubout"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .expect("openssl must be available"); child .stdin .take() .unwrap() .write_all(private_key_pem.as_bytes()) .unwrap(); let output = child.wait_with_output().unwrap(); assert!(output.status.success(), "openssl rsa -pubout failed"); String::from_utf8(output.stdout).unwrap() } impl AppState { pub fn new() -> Self { Self { apps: HashMap::new(), installations: Vec::new(), repositories: Vec::new(), pull_requests: HashMap::new(), active_tokens: HashMap::new(), projects: Vec::new(), releases: HashMap::new(), manifest_conversions: HashMap::new(), comments: Vec::new(), webhook_config: WebhookOptions::default(), next_installation_id: 1, next_pr_number: 1, viewer_id: "U_fakeviewer".to_string(), } } pub fn register_app(&mut self, config: AppOptions) { let public_key_pem = derive_public_key_pem(&config.private_key_pem); let app_id = config.app_id.clone(); self.apps.insert( app_id, RegisteredApp { config, public_key_pem, }, ); } pub fn add_installation( &mut self, app_id: &str, owner: &str, repos: Vec, suspended: bool, ) -> u64 { let id = self.next_installation_id; self.next_installation_id += 1; self.installations.push(Installation { id, app_id: app_id.to_string(), owner: owner.to_string(), repositories: repos, suspended, }); id } pub fn add_repository( &mut self, owner: &str, name: &str, branches: Vec, private: bool, ) { self.repositories.push(Repository { owner: owner.to_string(), name: name.to_string(), branches, default_branch: "main".to_string(), private, git_dir: None, }); } pub fn find_installation(&self, owner: &str, repo: &str) -> Option<&Installation> { self.installations .iter() .find(|i| i.owner == owner && i.repositories.iter().any(|r| r == repo)) } pub fn find_installation_by_id(&self, id: u64) -> Option<&Installation> { self.installations.iter().find(|i| i.id == id) } pub fn generate_access_token( &mut self, app_id: &str, installation_id: u64, repositories: Vec, permissions: serde_json::Value, ) -> String { let token = format!("ghs_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); self.active_tokens.insert( token.clone(), TokenInfo { app_id: app_id.to_string(), installation_id, repositories, permissions, }, ); token } pub fn validate_token(&self, token: &str) -> Option<&TokenInfo> { self.active_tokens.get(token) } } impl Default for AppState { fn default() -> Self { Self::new() } } /// Initialize a bare git repository at `{git_root}/{owner}/{repo}.git/`. /// Returns the path to the bare repo directory. pub fn init_bare_repo( git_root: &std::path::Path, owner: &str, repo: &str, ) -> Result { let repo_dir = git_root.join(owner).join(format!("{repo}.git")); if repo_dir.exists() { return Ok(repo_dir); } std::fs::create_dir_all(&repo_dir) .map_err(|e| format!("failed to create git dir {}: {e}", repo_dir.display()))?; let output = std::process::Command::new("git") .args(["init", "--bare"]) .arg(&repo_dir) .output() .map_err(|e| format!("failed to run git init: {e}"))?; if !output.status.success() { return Err(format!( "git init --bare failed: {}", String::from_utf8_lossy(&output.stderr) )); } // Enable http.receivepack so push works via git-http-backend let output = std::process::Command::new("git") .args(["config", "http.receivepack", "true"]) .current_dir(&repo_dir) .output() .map_err(|e| format!("failed to configure git repo: {e}"))?; if !output.status.success() { return Err(format!( "git config http.receivepack failed: {}", String::from_utf8_lossy(&output.stderr) )); } Ok(repo_dir) } /// Initialize bare git repos for all repositories in the state. /// Sets each repository's `git_dir` field. pub fn init_git_repos(state: &mut AppState, git_root: &std::path::Path) -> Result<(), String> { for repo in &mut state.repositories { let git_dir = init_bare_repo(git_root, &repo.owner, &repo.name)?; repo.git_dir = Some(git_dir); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn empty_state_has_no_apps() { let state = AppState::new(); assert!(state.apps.is_empty()); } fn test_rsa_key() -> String { use std::process::Command; let output = Command::new("openssl") .args([ "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", ]) .output() .expect("openssl should be available"); assert!(output.status.success()); String::from_utf8(output.stdout).unwrap() } #[test] fn repository_has_private_field() { let mut state = AppState::new(); state.add_repository("owner", "private-repo", vec!["main".to_string()], true); let repo = state .repositories .iter() .find(|r| r.name == "private-repo") .unwrap(); assert!(repo.private); } #[test] fn git_root_initializes_bare_repo() { let tmp = tempfile::tempdir().unwrap(); let git_root = tmp.path().to_path_buf(); let repo_git_dir = init_bare_repo(&git_root, "acme", "widgets").unwrap(); assert!(repo_git_dir.join("HEAD").exists()); assert!(repo_git_dir.join("objects").exists()); } #[test] fn can_register_app() { let pem = test_rsa_key(); let mut state = AppState::new(); state.register_app(AppOptions { app_id: "12345".to_string(), slug: "test-app".to_string(), owner_login: "test-owner".to_string(), public: true, private_key_pem: pem, webhook_secret: Some("secret".to_string()), }); assert_eq!(state.apps.len(), 1); assert_eq!(state.apps["12345"].config.slug, "test-app"); // Verify public key was derived assert!(state.apps["12345"].public_key_pem.contains("PUBLIC KEY")); } }