use bytes::Bytes; use globset::{Glob, GlobMatcher}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; use crate::share::policy::{ WhitelistSet, WhitelistSubject, load_branch_whitelists, load_tag_whitelists, }; use crate::smart::error::{SmartError, SmartResult}; use crate::smart::pktline; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RefUpdate { pub old_oid: String, pub new_oid: String, pub refname: String, } #[derive(Debug, Clone)] pub enum RefVerdict { Accept, Reject(String), } #[derive(Debug, Clone)] pub struct PreReceiveVerdict { pub per_ref: Vec<(RefUpdate, RefVerdict)>, } impl PreReceiveVerdict { pub fn accepted_only(self) -> SmartResult> { let mut accepted = Vec::with_capacity(self.per_ref.len()); for (r, v) in self.per_ref { match v { RefVerdict::Accept => accepted.push(r), RefVerdict::Reject(msg) => return Err(SmartError::Forbidden(msg)), } } Ok(accepted) } } pub async fn validate_pre_receive( db: &sea_orm::DatabaseConnection, baby: &gitbaby::GitBaby, repo_id: Uuid, actor_user_id: Uuid, refs: &[RefUpdate], ) -> SmartResult { tracing::debug!( %repo_id, %actor_user_id, ref_count = refs.len(), "validating pre-receive ref updates" ); let repo = models::repo::repos::Entity::find_by_id(repo_id) .one(db) .await? .ok_or_else(|| SmartError::NotFound(format!("repo {repo_id}")))?; let branch_policies = models::repo::repo_branch_protection_policy::Entity::find() .filter(models::repo::repo_branch_protection_policy::Column::RepoId.eq(repo.id)) .all(db) .await?; let tag_policies = models::repo::repo_tag_protection_policy::Entity::find() .filter(models::repo::repo_tag_protection_policy::Column::RepoId.eq(repo.id)) .all(db) .await?; let actor_collab = models::repo::repo_collaborations::Entity::find() .filter(models::repo::repo_collaborations::Column::RepoId.eq(repo.id)) .filter(models::repo::repo_collaborations::Column::UserId.eq(actor_user_id)) .one(db) .await?; let actor_is_admin = matches!( actor_collab.map(|c| c.mode), Some(models::repo::repo_collaborations::CollaborationMode::Admin) ); tracing::debug!( %repo_id, branch_policies = branch_policies.len(), tag_policies = tag_policies.len(), actor_is_admin, "loaded protection policies for pre-receive" ); let branch_policy_ids: Vec = branch_policies.iter().map(|p| p.id).collect(); let branch_whitelists = load_branch_whitelists(db, &branch_policy_ids) .await .map_err(|e| SmartError::Internal(format!("load branch whitelists: {e}")))?; let tag_policy_ids: Vec = tag_policies.iter().map(|p| p.id).collect(); let tag_whitelists = load_tag_whitelists(db, &tag_policy_ids) .await .map_err(|e| SmartError::Internal(format!("load tag whitelists: {e}")))?; let oid_len = oid_len_of(refs); let zero_oid = "0".repeat(oid_len); let mut out = Vec::with_capacity(refs.len()); for r in refs { let verdict = judge( baby, r, &branch_policies, &tag_policies, actor_user_id, actor_is_admin, &branch_whitelists, &tag_whitelists, &zero_oid, ) .await?; match &verdict { RefVerdict::Accept => { tracing::debug!(refname = %r.refname, "pre-receive ref accepted"); } RefVerdict::Reject(reason) => { tracing::warn!(refname = %r.refname, %reason, "pre-receive ref rejected"); } } out.push((r.clone(), verdict)); } Ok(PreReceiveVerdict { per_ref: out }) } async fn judge( baby: &gitbaby::GitBaby, r: &RefUpdate, branch_policies: &[models::repo::repo_branch_protection_policy::Model], tag_policies: &[models::repo::repo_tag_protection_policy::Model], actor_user_id: Uuid, actor_is_admin: bool, branch_whitelists: &WhitelistSet, tag_whitelists: &WhitelistSet, zero_oid: &str, ) -> SmartResult { if let Some(branch) = r.refname.strip_prefix("refs/heads/") { return judge_branch( baby, branch, r, branch_policies, actor_user_id, actor_is_admin, branch_whitelists, zero_oid, ) .await; } if let Some(tag) = r.refname.strip_prefix("refs/tags/") { return Ok(judge_tag( tag, tag_policies, actor_user_id, actor_is_admin, tag_whitelists, )); } Ok(RefVerdict::Accept) } async fn judge_branch( baby: &gitbaby::GitBaby, branch: &str, r: &RefUpdate, branch_policies: &[models::repo::repo_branch_protection_policy::Model], actor_user_id: Uuid, actor_is_admin: bool, whitelists: &WhitelistSet, zero_oid: &str, ) -> SmartResult { let matchers: Vec<( &models::repo::repo_branch_protection_policy::Model, GlobMatcher, )> = match branch_policies .iter() .filter_map(|m| { Glob::new(&m.branch_pattern) .ok() .map(|g| (m, g.compile_matcher())) }) .find(|(_, matcher)| matcher.is_match(branch)) { Some(found) => vec![found], None => Vec::new(), }; if matchers.is_empty() { return Ok(RefVerdict::Accept); } let policy = matchers[0].0; let is_delete = r.new_oid == zero_oid; let is_force = r.old_oid != zero_oid && r.new_oid != zero_oid && !is_fast_forward(baby, &r.old_oid, &r.new_oid).await?; if is_delete { return Ok(RefVerdict::Reject(format!( "deleting protected branch `{branch}` is not allowed" ))); } if !policy.enable_push { if policy.enable_push_whitelist && whitelists.push_actor_allowed(WhitelistSubject::User(actor_user_id)) { return Ok(RefVerdict::Accept); } return Ok(RefVerdict::Reject(format!( "pushes to protected branch `{branch}` are not allowed" ))); } if is_force && !policy.enable_force_push && !actor_is_admin { if whitelists.push_actor_allowed(WhitelistSubject::User(actor_user_id)) { return Ok(RefVerdict::Accept); } return Ok(RefVerdict::Reject(format!( "force-push to protected branch `{branch}` is not allowed" ))); } Ok(RefVerdict::Accept) } fn judge_tag( tag: &str, tag_policies: &[models::repo::repo_tag_protection_policy::Model], actor_user_id: Uuid, actor_is_admin: bool, whitelists: &WhitelistSet, ) -> RefVerdict { for p in tag_policies { let Ok(glob) = Glob::new(&p.name_pattern) else { continue; }; if !glob.compile_matcher().is_match(tag) { continue; } if actor_is_admin || whitelists.tag_modifier_allowed(actor_user_id) { return RefVerdict::Accept; } return RefVerdict::Reject(format!( "tag `{tag}` is protected; only the policy owner(s) can modify it" )); } RefVerdict::Accept } /// Real fast-forward check via `git merge-base --is-ancestor old new`. /// `old == new` (no-op push) and `old == zero_oid` (new branch) are not /// ancestors checks — they short-circuit to true without spawning git. async fn is_fast_forward(baby: &gitbaby::GitBaby, old: &str, new: &str) -> SmartResult { if old == new { return Ok(true); } if old.chars().all(|c| c == '0') || new.chars().all(|c| c == '0') { return Ok(false); } baby.is_ancestor(old, new) .await .map_err(|e| SmartError::Internal(format!("is_ancestor failed: {e}"))) } fn oid_len_of(refs: &[RefUpdate]) -> usize { refs.iter() .find_map(|r| { let max = r.old_oid.len().max(r.new_oid.len()); (max == 40 || max == 64).then_some(max) }) .unwrap_or(40) } pub fn parse_receive_pack_refs(input: &[u8]) -> SmartResult> { let lines = pktline::decode_stream(input) .map_err(|e| SmartError::BadRequest(format!("invalid receive-pack request: {e}")))?; let mut out = Vec::new(); for mut line in lines { if let Some(nul) = line.iter().position(|b| *b == 0) { line.truncate(nul); } if line.is_empty() { continue; } let s = std::str::from_utf8(&line).map_err(|_| { SmartError::BadRequest("receive-pack ref line is not valid UTF-8".to_string()) })?; let mut parts = s.splitn(3, ' '); let old = parts.next().unwrap_or_default(); let new = parts.next().unwrap_or_default(); let refname = parts.next().unwrap_or_default(); if old.is_empty() || new.is_empty() || refname.is_empty() { return Err(SmartError::BadRequest(format!( "malformed receive-pack ref line: {s}" ))); } let oid_len = old.len(); if oid_len != 40 && oid_len != 64 { return Err(SmartError::BadRequest(format!( "invalid oid length {oid_len} in receive-pack ref line: {s}" ))); } if new.len() != oid_len || !old.chars().all(|c| c.is_ascii_hexdigit()) || !new.chars().all(|c| c.is_ascii_hexdigit()) { return Err(SmartError::BadRequest(format!( "invalid oid in receive-pack ref line: {s}" ))); } out.push(RefUpdate { old_oid: old.to_string(), new_oid: new.to_string(), refname: refname.to_string(), }); } Ok(out) } #[allow(dead_code)] pub fn update_pkts(input: &Bytes) -> SmartResult> { parse_receive_pack_refs(input) }