//! Shared repo resolution + authorization + git process construction for smart-HTTP and SSH. //! Both transports MUST go through these functions so the rules live in one place. use std::path::PathBuf; use forge_core::{Action, Denied, Repo, User, authorize}; use forge_git::{GitCommand, GitService}; use crate::state::AppState; #[derive(Debug, PartialEq, Eq)] pub enum AccessError { /// No credentials were given and they're needed (HTTP: 401 + WWW-Authenticate). Unauthorized, /// Repo doesn't exist OR the actor can't see it. Never distinguish the two. NotFound, /// Actor can see the repo but lacks the right (e.g. push to read-only / archived). Forbidden, Internal(String), } pub enum Transport { Http, Ssh, } impl Transport { pub fn as_str(&self) -> &'static str { match self { Transport::Http => "http", Transport::Ssh => "ssh", } } } fn internal(e: impl std::fmt::Display) -> AccessError { AccessError::Internal(e.to_string()) } /// Anonymous callers get Unauthorized (so git prompts for credentials) for every /// "cannot use this repo" outcome, so missing and hidden repos look identical to them. fn hidden_or_missing(actor: Option<&User>) -> AccessError { if actor.is_none() { AccessError::Unauthorized } else { AccessError::NotFound } } /// Look up `owner/name` (a trailing ".git" on `name` is stripped), then authorize `actor`: /// UploadPack needs Read and ReceivePack needs Write. Returns the repo and its on-disk path. pub async fn resolve_repo( state: &AppState, actor: Option<&User>, owner: &str, name: &str, service: GitService, ) -> Result<(Repo, PathBuf), AccessError> { let action = match service { GitService::UploadPack => Action::Read, GitService::ReceivePack => Action::Write, }; // `.wiki.git` (or `.wiki`) is the repo's wiki: same owner, same Read/Write rules, its own // bare repo, and it is hidden (not found) while the wiki is switched off. if let Some(base) = forge_core::names::wiki_base(name) { let repo = resolve_repo_for(state, actor, owner, base, action).await?; if !state.store.wiki_enabled(&repo.id).await.map_err(internal)? { return Err(hidden_or_missing(actor)); } let path = forge_git::repo::wiki_path(&state.config.data_dir, &repo.id).map_err(internal)?; return Ok((repo, path)); } let repo = resolve_repo_for(state, actor, owner, name, action).await?; let path = forge_git::repo::repo_path(&state.config.data_dir, &repo.id).map_err(internal)?; Ok((repo, path)) } /// The repo at `owner/name` (a trailing ".git" is stripped, and a past name is followed), or /// `None`. No authorization. Shared by the transports that check tokens instead of users. pub async fn find_repo( state: &AppState, owner: &str, name: &str, ) -> Result, AccessError> { let name = name.strip_suffix(".git").unwrap_or(name); // A past owner/name (left by a rename or transfer) is served as the repo it points at. The // checks run on that repo, so the redirect grants nothing extra. match state .store .repo_by_owner_and_name(owner, name) .await .map_err(internal)? { Some(repo) => Ok(Some(repo)), None => state .store .repo_by_redirect(owner, name) .await .map_err(internal), } } /// [`find_repo`], then authorize `actor` for `action`. A missing repo and a hidden one give the /// same answer. pub async fn resolve_repo_for( state: &AppState, actor: Option<&User>, owner: &str, name: &str, action: Action, ) -> Result { let Some(repo) = find_repo(state, owner, name).await? else { return Err(hidden_or_missing(actor)); }; let (role, org_role) = match actor { Some(user) => ( state .store .collaborator_role(&repo.id, &user.id) .await .map_err(internal)?, state .store .org_repo_role(&repo.owner_id, &user.id, &repo.id) .await .map_err(internal)?, ), None => (None, None), }; authorize(actor, &repo, role, org_role, action).map_err(|denied| match denied { Denied::NotFound => hidden_or_missing(actor), Denied::Forbidden if actor.is_none() => AccessError::Unauthorized, Denied::Forbidden => AccessError::Forbidden, })?; Ok(repo) } /// Build the git process for `service` on `repo_path`. For ReceivePack it also sets the hook env /// (FORGE_INTERNAL_SOCKET, FORGE_INTERNAL_TOKEN, FORGE_ACTOR_ID, FORGE_REPO_ID, FORGE_PROTOCOL). /// HTTP passes stateless_rpc=true; SSH passes false. `advertise_refs` is used only by HTTP info/refs. // The signature is shared with the SSH backend, so the argument count stays. #[allow(clippy::too_many_arguments)] pub fn git_process( state: &AppState, repo: &Repo, repo_path: &std::path::Path, actor: Option<&User>, service: GitService, transport: Transport, advertise_refs: bool, git_protocol: Option<&str>, ) -> GitCommand { let stateless_rpc = matches!(transport, Transport::Http); let cmd = forge_git::service_command( &state.git, service, repo_path, stateless_rpc, advertise_refs, git_protocol, ); if service != GitService::ReceivePack { return cmd; } let is_wiki = repo_path .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.ends_with(".wiki.git")); let actor_id = actor.map_or("", |user| user.id.as_str()); cmd.env("FORGE_INTERNAL_SOCKET", state.internal_socket.as_os_str()) .env("FORGE_INTERNAL_TOKEN", &*state.internal_token) .env("FORGE_ACTOR_ID", actor_id) .env("FORGE_REPO_ID", repo.id.as_str()) .env("FORGE_PROTOCOL", transport.as_str()) .env("FORGE_WIKI", if is_wiki { "1" } else { "0" }) }