Skip to content
Clone
f0a1552
322 lines 10.0 KB Rust
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<Vec<RefUpdate>> {
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<PreReceiveVerdict> {
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<Uuid> = 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<Uuid> = 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<RefVerdict> {
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<RefVerdict> {
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<bool> {
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<Vec<RefUpdate>> {
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<Vec<RefUpdate>> {
parse_receive_pack_refs(input)
}