AkurAI Build
Menu

Public repository / main

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
        );
    }
}