Menu
Public repository / 3367b5f4b77e4085d5cc513b20e67403d2207543
AkurAI-Build / src/protection.rs
Latest change cd8019fddc46769e797dc54194decfd9d4a98f2d - OLI-270: branch protection — rules per branch, pre-receive check, merge gate by Óli
//! Branch protection (OLI-270): per-branch rules checked by the hosted repos'
//! pre-receive hook (through `POST /api/repos/{repo}/protection/check`) and by
//! the pull-request merge. A branch without a rule is unprotected; every
//! repository's default branch gets the default rule (no force-push, no delete)
//! when it is hosted or at startup.
use anyhow::{Context, Result, bail};
use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::db::Database;
use crate::pulls;
use crate::statuses::{self, Rollup};
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Rule {
pub branch: String,
#[serde(default)]
pub allow_force_push: bool,
#[serde(default)]
pub allow_delete: bool,
#[serde(default)]
pub require_green: bool,
#[serde(default)]
pub require_review: bool,
}
impl Rule {
/// No force-push, no deletion; checks and reviews are opt-in.
pub fn default_for(branch: &str) -> Rule {
Rule {
branch: branch.to_owned(),
allow_force_push: false,
allow_delete: false,
require_green: false,
require_review: false,
}
}
}
/// One ref update as git's pre-receive hook sees it.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Push {
pub reference: String,
pub old: String,
pub new: String,
/// `old` is an ancestor of `new` (or the ref is created).
pub fast_forward: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Decision {
pub allowed: bool,
pub reason: String,
}
impl Decision {
fn allow(reason: &str) -> Decision {
Decision {
allowed: true,
reason: reason.to_owned(),
}
}
fn deny(reason: String) -> Decision {
Decision {
allowed: false,
reason,
}
}
}
fn repository_id(connection: &rusqlite::Connection, name: &str) -> Result<i64> {
connection
.query_row(
"SELECT id FROM repositories WHERE name = ?1",
[name],
|row| row.get(0),
)
.optional()?
.with_context(|| format!("repository not found: {name}"))
}
fn validate_branch(branch: &str) -> Result<()> {
let ok = !branch.is_empty()
&& branch.len() <= 200
&& !branch.starts_with('/')
&& !branch.ends_with('/')
&& !branch.contains("..")
&& !branch.starts_with("refs/")
&& branch
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'));
if !ok {
bail!("invalid branch name {branch:?}");
}
Ok(())
}
pub fn rules(database: &Database, repository: &str) -> Result<Vec<Rule>> {
let connection = database.connection()?;
let id = repository_id(&connection, repository)?;
let mut statement = connection.prepare(
"SELECT branch, allow_force_push, allow_delete, require_green, require_review
FROM branch_protection WHERE repository_id = ?1 ORDER BY branch",
)?;
let rows = statement.query_map([id], |row| {
Ok(Rule {
branch: row.get(0)?,
allow_force_push: row.get::<_, i64>(1)? != 0,
allow_delete: row.get::<_, i64>(2)? != 0,
require_green: row.get::<_, i64>(3)? != 0,
require_review: row.get::<_, i64>(4)? != 0,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn rule_for(database: &Database, repository: &str, branch: &str) -> Result<Option<Rule>> {
Ok(rules(database, repository)?
.into_iter()
.find(|r| r.branch == branch))
}
pub fn set_rule(database: &Database, repository: &str, rule: &Rule) -> Result<Rule> {
validate_branch(&rule.branch)?;
let connection = database.connection()?;
let id = repository_id(&connection, repository)?;
connection.execute(
"INSERT INTO branch_protection
(repository_id, branch, allow_force_push, allow_delete, require_green, require_review)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(repository_id, branch) DO UPDATE SET
allow_force_push = excluded.allow_force_push,
allow_delete = excluded.allow_delete,
require_green = excluded.require_green,
require_review = excluded.require_review",
params![
id,
rule.branch,
rule.allow_force_push as i64,
rule.allow_delete as i64,
rule.require_green as i64,
rule.require_review as i64,
],
)?;
Ok(rule.clone())
}
pub fn remove_rule(database: &Database, repository: &str, branch: &str) -> Result<bool> {
let connection = database.connection()?;
let id = repository_id(&connection, repository)?;
Ok(connection.execute(
"DELETE FROM branch_protection WHERE repository_id = ?1 AND branch = ?2",
params![id, branch],
)? > 0)
}
/// Give every repository the default rule on its default branch unless it has
/// one already. Returns how many rules were added.
pub fn seed_defaults(database: &Database) -> Result<usize> {
let mut added = 0;
for repository in database.repositories()? {
if rule_for(database, &repository.name, &repository.default_branch)?.is_none() {
set_rule(
database,
&repository.name,
&Rule::default_for(&repository.default_branch),
)?;
added += 1;
}
}
Ok(added)
}
/// The branch name of a `refs/heads/...` reference.
pub fn branch_of(reference: &str) -> Option<&str> {
reference.strip_prefix("refs/heads/")
}
/// Whether the commit has a green combined status on this forge.
fn green(database: &Database, repository: &str, sha: &str) -> Result<bool> {
Ok(statuses::combined(database, repository, sha)?.state == Rollup::Success)
}
/// Whether a merged pull request produced `sha` and carries an approval (the
/// latest review per author counts; a request for changes blocks).
fn approved_merge(database: &Database, repository: &str, sha: &str) -> Result<bool> {
let connection = database.connection()?;
let number: Option<i64> = connection
.query_row(
"SELECT p.number FROM pull_requests p JOIN repositories r ON r.id = p.repository_id
WHERE r.name = ?1 AND p.merge_sha = ?2",
params![repository, sha],
|row| row.get(0),
)
.optional()?;
drop(connection);
match number {
Some(number) => approved(database, repository, number),
None => Ok(false),
}
}
/// Approved and not blocked: the latest review of at least one author is
/// `approved` and no author's latest review is `changes_requested`.
pub fn approved(database: &Database, repository: &str, number: i64) -> Result<bool> {
let mut latest: std::collections::BTreeMap<String, String> = Default::default();
for review in pulls::reviews(database, repository, number)? {
if review.state != "commented" {
latest.insert(review.author, review.state);
}
}
let blocked = latest.values().any(|s| s == "changes_requested");
let has_approval = latest.values().any(|s| s == "approved");
Ok(has_approval && !blocked)
}
/// The pre-receive decision for one ref update.
pub fn check_push(database: &Database, repository: &str, push: &Push) -> Result<Decision> {
let Some(branch) = branch_of(&push.reference) else {
return Ok(Decision::allow("not a branch"));
};
let Some(rule) = rule_for(database, repository, branch)? else {
return Ok(Decision::allow("branch is not protected"));
};
if push.new == ZERO_SHA {
return Ok(if rule.allow_delete {
Decision::allow("deletion allowed")
} else {
Decision::deny(format!(
"branch {branch} is protected: it cannot be deleted"
))
});
}
if !push.fast_forward && push.old != ZERO_SHA && !rule.allow_force_push {
return Ok(Decision::deny(format!(
"branch {branch} is protected: force-push is not allowed"
)));
}
if rule.require_green && !green(database, repository, &push.new)? {
return Ok(Decision::deny(format!(
"branch {branch} requires a green run for {}; run the pipeline on the commit first",
short(&push.new)
)));
}
if rule.require_review && !approved_merge(database, repository, &push.new)? {
return Ok(Decision::deny(format!(
"branch {branch} requires an approved pull request: {} is not the merge commit of an approved pull request on this forge",
short(&push.new)
)));
}
Ok(Decision::allow("protected branch rules satisfied"))
}
/// The merge-API decision: the base branch's rule applied to the pull request
/// (its head commit must be green, its reviews must approve).
pub fn check_merge(
database: &Database,
repository: &str,
base_branch: &str,
number: i64,
head_sha: &str,
) -> Result<Decision> {
let Some(rule) = rule_for(database, repository, base_branch)? else {
return Ok(Decision::allow("branch is not protected"));
};
if rule.require_green && !green(database, repository, head_sha)? {
return Ok(Decision::deny(format!(
"branch {base_branch} requires a green run; {} has none",
short(head_sha)
)));
}
if rule.require_review && !approved(database, repository, number)? {
return Ok(Decision::deny(format!(
"branch {base_branch} requires an approved review on pull request #{number}"
)));
}
Ok(Decision::allow("protected branch rules satisfied"))
}
fn short(sha: &str) -> &str {
&sha[..sha.len().min(12)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pulls::ReviewState;
use crate::runner::Runner;
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn database() -> Database {
let db = Database::memory(KEY).expect("db");
db.add_repository("demo", "https://example.com/demo.git", "main")
.expect("repo");
db
}
fn push(reference: &str, old: &str, new: &str, fast_forward: bool) -> Push {
Push {
reference: reference.into(),
old: old.into(),
new: new.into(),
fast_forward,
}
}
#[test]
fn rules_are_stored_per_branch_and_seeded_for_the_default_branch() {
let db = database();
assert!(rules(&db, "demo").expect("ok").is_empty());
assert_eq!(seed_defaults(&db).expect("ok"), 1);
assert_eq!(seed_defaults(&db).expect("ok"), 0, "idempotent");
let main = rule_for(&db, "demo", "main").expect("ok").expect("ok");
assert_eq!(main, Rule::default_for("main"));
set_rule(
&db,
"demo",
&Rule {
require_green: true,
..Rule::default_for("main")
},
)
.expect("ok");
assert!(
rule_for(&db, "demo", "main")
.expect("ok")
.expect("ok")
.require_green
);
assert!(set_rule(&db, "demo", &Rule::default_for("refs/heads/x")).is_err());
assert!(set_rule(&db, "nope", &Rule::default_for("main")).is_err());
assert!(remove_rule(&db, "demo", "main").expect("ok"));
assert!(!remove_rule(&db, "demo", "main").expect("ok"));
}
#[test]
fn unprotected_branches_and_tags_pass_and_the_default_rule_blocks_force_and_delete() {
let db = database();
seed_defaults(&db).expect("ok");
assert!(
check_push(&db, "demo", &push("refs/heads/topic", A, B, false))
.expect("ok")
.allowed
);
assert!(
check_push(&db, "demo", &push("refs/tags/v1", ZERO_SHA, B, true))
.expect("ok")
.allowed
);
assert!(
check_push(&db, "demo", &push("refs/heads/main", A, B, true))
.expect("ok")
.allowed
);
let force = check_push(&db, "demo", &push("refs/heads/main", A, B, false)).expect("ok");
assert!(!force.allowed && force.reason.contains("force-push"));
let delete =
check_push(&db, "demo", &push("refs/heads/main", A, ZERO_SHA, false)).expect("ok");
assert!(!delete.allowed && delete.reason.contains("deleted"));
// creating the branch is fine even though old is zero
assert!(
check_push(&db, "demo", &push("refs/heads/main", ZERO_SHA, B, true))
.expect("ok")
.allowed
);
}
#[test]
fn require_green_needs_a_succeeded_run_on_the_pushed_commit() {
let db = database();
set_rule(
&db,
"demo",
&Rule {
require_green: true,
..Rule::default_for("main")
},
)
.expect("ok");
let no_run = check_push(&db, "demo", &push("refs/heads/main", A, B, true)).expect("ok");
assert!(!no_run.allowed && no_run.reason.contains("green run"));
let temp = tempfile::tempdir().expect("ok");
let runner = Runner::new(db.clone(), temp.path().to_owned(), false).expect("ok");
let id = runner
.queue("demo", Some("main"), Some(B), "manual")
.expect("ok");
db.finish_run(id, "failed", None).expect("ok");
assert!(
!check_push(&db, "demo", &push("refs/heads/main", A, B, true))
.expect("ok")
.allowed
);
let id = runner
.queue("demo", Some("main"), Some(B), "manual")
.expect("ok");
db.finish_run(id, "succeeded", None).expect("ok");
assert!(
check_push(&db, "demo", &push("refs/heads/main", A, B, true))
.expect("ok")
.allowed
);
}
#[test]
fn require_review_needs_an_approved_pull_request_that_produced_the_commit() {
let db = database();
set_rule(
&db,
"demo",
&Rule {
require_review: true,
..Rule::default_for("main")
},
)
.expect("ok");
let denied = check_push(&db, "demo", &push("refs/heads/main", A, B, true)).expect("ok");
assert!(!denied.allowed && denied.reason.contains("approved pull request"));
let pull = pulls::create(
&db,
"demo",
&pulls::NewPullRequest {
title: "feature",
body: "",
author: "ada",
base_ref: "main",
head_ref: "topic",
},
)
.expect("ok");
assert!(!approved(&db, "demo", pull.number).expect("ok"));
pulls::add_review(
&db,
"demo",
pull.number,
"grace",
ReviewState::ChangesRequested,
"no",
)
.expect("ok");
pulls::add_review(
&db,
"demo",
pull.number,
"bob",
ReviewState::Approved,
"yes",
)
.expect("ok");
assert!(
!approved(&db, "demo", pull.number).expect("ok"),
"a request for changes blocks"
);
pulls::add_review(
&db,
"demo",
pull.number,
"grace",
ReviewState::Approved,
"ok now",
)
.expect("ok");
assert!(approved(&db, "demo", pull.number).expect("ok"));
assert!(
check_merge(&db, "demo", "main", pull.number, B)
.expect("ok")
.allowed
);
pulls::record_merge(&db, "demo", pull.number, "merge", B, "ada").expect("ok");
assert!(
check_push(&db, "demo", &push("refs/heads/main", A, B, true))
.expect("ok")
.allowed
);
assert!(
!check_push(
&db,
"demo",
&push(
"refs/heads/main",
A,
"cccccccccccccccccccccccccccccccccccccccc",
true
)
)
.expect("ok")
.allowed
);
}
#[test]
fn check_merge_applies_the_base_branch_rule_to_the_pull_request() {
let db = database();
assert!(
check_merge(&db, "demo", "main", 1, B).expect("ok").allowed,
"no rule"
);
set_rule(
&db,
"demo",
&Rule {
require_green: true,
require_review: true,
..Rule::default_for("main")
},
)
.expect("ok");
let pull = pulls::create(
&db,
"demo",
&pulls::NewPullRequest {
title: "feature",
body: "",
author: "ada",
base_ref: "main",
head_ref: "topic",
},
)
.expect("ok");
let d = check_merge(&db, "demo", "main", pull.number, B).expect("ok");
assert!(!d.allowed && d.reason.contains("green run"));
let temp = tempfile::tempdir().expect("ok");
let runner = Runner::new(db.clone(), temp.path().to_owned(), false).expect("ok");
let id = runner
.queue("demo", Some("topic"), Some(B), "manual")
.expect("ok");
db.finish_run(id, "succeeded", None).expect("ok");
let d = check_merge(&db, "demo", "main", pull.number, B).expect("ok");
assert!(!d.allowed && d.reason.contains("approved review"));
pulls::add_review(
&db,
"demo",
pull.number,
"bob",
ReviewState::Approved,
"yes",
)
.expect("ok");
assert!(
check_merge(&db, "demo", "main", pull.number, B)
.expect("ok")
.allowed
);
}
}