Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,8 @@ capture) through F6 (acceptance on the first deployment); F0, F1 (server
skeleton, users, organizations, tokens, `admin` CLI), F2a (repositories API, lifecycle
and reconciler, collaborators, branches) and F2b (smart-HTTP git transport via `git http-backend`,
`pre-receive` ref policy, admission control, threat-model pass #1) and F3a (content reads: refs, tags, commits,
trees, contents, archives) and F3b (contents writes: create, update, delete as server-made commits) are built. Hosted repos are
trees, contents, archives) and F3b (contents writes: create, update, delete as server-made commits) and F4a (branch protection
enforced on push, contents API and branch delete; commit statuses) are built. Hosted repos are
bare Git and get none of the `.sc/` features.

Other follow-up work is tracked as GitHub issues and listed under Deferred
Expand Down
90 changes: 87 additions & 3 deletions crates/forge/conformance/departures.json
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
]
},
"reason": "Gitea answers 500 for an unknown sha; sc-forge answers 404"
Expand All @@ -362,7 +362,7 @@
"expect": {
"status": 404,
"message_contains": [
"not found"
"couldn't be found"
]
},
"reason": "Gitea answers 500 for an unknown sha; sc-forge answers 404"
Expand Down Expand Up @@ -1285,7 +1285,7 @@
"status": 404,
"ignore_body": true,
"message_contains": [
"not found"
"couldn't be found"
],
"ignore_headers": [
"link",
Expand Down Expand Up @@ -1441,5 +1441,89 @@
"value": ""
},
"reason": "HEAD stays on the unborn default branch, so ls-remote --symref HEAD prints nothing (Gitea moved HEAD to the first pushed branch)"
},
{
"scenario": "H",
"exchange": 37,
"kind": "http",
"match": "POST /api/v1/repos/acme/status/statuses/<sha#4>",
"expect": {
"status": 422,
"message_contains": [
"state"
],
"ignore_body": true
},
"reason": "sc-forge requires a state: a status without one is 422 [ignore_body: the recorded 201 body has another shape than the departed answer]"
},
{
"scenario": "H",
"exchange": 50,
"kind": "http",
"match": "GET /api/v1/repos/acme/status/statuses/main?limit=100",
"expect": {
"status": 200,
"ignore_body": true,
"fields": {
"$len": 0
},
"ignore_headers": [
"link",
"x-total-count"
]
},
"reason": "the only status on this commit was the refused bogus state, so the list is empty; ignore_body: the recorded body is a one-element list"
},
{
"scenario": "H",
"exchange": 1,
"kind": "note",
"match": "combined_state_no_statuses",
"expect": {
"value": "pending"
},
"reason": "existing commit with no statuses: combined state pending (1.26.2 behaviour); 1.22.6's empty string is an empty answer"
},
{
"scenario": "I",
"exchange": 7,
"kind": "git",
"match": "push-protected-write-collab",
"expect": {
"ok": false,
"message_contains": [
"protected branch",
"pre-receive hook declined"
]
},
"reason": "sc-forge's pre-receive words the refusal 'refusing to update protected branch <b>: direct pushes are not allowed'; Gitea says 'gitea: not allowed to push to protected branch <b>'. Both end in 'pre-receive hook declined'"
},
{
"scenario": "I",
"exchange": 8,
"kind": "git",
"match": "push-protected-admin",
"expect": {
"ok": false,
"message_contains": [
"protected branch",
"pre-receive hook declined"
]
},
"reason": "sc-forge's pre-receive words the refusal 'refusing to update protected branch <b>: direct pushes are not allowed'; Gitea says 'gitea: not allowed to push to protected branch <b>'. Both end in 'pre-receive hook declined'"
},
{
"scenario": "I",
"exchange": 10,
"kind": "git",
"match": "delete-protected-by-push",
"expect": {
"ok": false,
"message_contains": [
"default branch",
"pre-receive hook declined"
]
},
"reason": "sc-forge's hook words the refusal 'refusing to delete the default branch'; Gitea says 'gitea: branch main is the default branch and cannot be deleted'. Both end in 'pre-receive hook declined'"
}
]
2 changes: 1 addition & 1 deletion crates/forge/conformance/forge-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ for _ in $(seq 50); do
done
python3 -I "$here/run.py" check --base-url "http://127.0.0.1:$port" --admin-token "$token" \
--fixtures "$here/fixtures/1.22.6" --departures "$here/departures.json" --pending "$here/slice-pending.json" \
--only A,B,C,D,E,J,K,L,M,N,R,V "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
--only A,B,C,D,E,H,I,J,K,L,M,N,Q,R,V,W "$@" || { echo "--- server log"; cat "$data/serve.log"; exit 1; }
14 changes: 6 additions & 8 deletions crates/forge/conformance/slice-pending.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
{
"B": {
"defer": [
[
30,
"POST /api/v1/repos/alice/scoped/statuses/<sha#2>"
]
]
},
"D": {
"defer": [
[
Expand All @@ -26,5 +18,11 @@
"GET /api/v1/orgs/acme/repos?limit=50"
]
]
},
"Q": {
"stop": [
5,
"expected [201]"
]
}
}
22 changes: 17 additions & 5 deletions crates/forge/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ pub(crate) fn paged_with(
extra: &[(&str, String)],
items: Vec<Value>,
) -> Resp {
let mut r = Resp::json(200, &Value::Array(items)).with("x-total-count", total.to_string());
if let Some(l) = link_header(ctx, path, total, page, limit, extra) {
r = r.with("link", l);
}
r
}

/// Gitea's `Link` header value for page `page` of `total` rows, or `None` when there is one page.
pub(crate) fn link_header(
ctx: &Ctx,
path: &str,
total: i64,
page: i64,
limit: i64,
extra: &[(&str, String)],
) -> Option<String> {
let tail: String = extra.iter().map(|(k, v)| format!("&{k}={v}")).collect();
let last = ((total + limit - 1) / limit).max(1);
let url = |p: i64, rel: &str| {
Expand All @@ -165,11 +181,7 @@ pub(crate) fn paged_with(
links.push(url(1, "first"));
links.push(url(page - 1, "prev"));
}
let mut r = Resp::json(200, &Value::Array(items)).with("x-total-count", total.to_string());
if !links.is_empty() {
r = r.with("link", links.join(","));
}
r
(!links.is_empty()).then(|| links.join(","))
}

/// Like [`paged`], but a page link repeats *every* request parameter, alphabetically by name (what
Expand Down
1 change: 1 addition & 0 deletions crates/forge/src/cgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ fn environment(l: &Launch, marker: &std::path::Path) -> Vec<(String, String)> {
var("GIT_HTTP_MAX_REQUEST_BUFFER", cfg.max_request.to_string());
var("FORGE_DATA_DIR", git.data_dir().display().to_string());
var("FORGE_REPO_ID", id.to_string());
var("FORGE_GIT_BIN", git.bin().display().to_string());
var("FORGE_USER_ID", l.plan.user.id.to_string());
var("FORGE_ACCEPT_MARKER", marker.display().to_string());
env
Expand Down
13 changes: 11 additions & 2 deletions crates/forge/src/content_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,12 @@ fn run(ctx: &Ctx, a: &Access, row: &Repo, w: &Write) -> Resp {
let new_branch = w.new_branch.clone().filter(|n| *n != base_name);
let target_name = new_branch.clone().unwrap_or_else(|| base_name.clone());
// Gitea checks protection before it looks at the tree.
if policy::check_contents_write(&target_name, new_branch.is_some()).is_err() {
// The rules are read here, under the write gate, so a rule change is ordered against this write.
let rules = match crate::protection::load(ctx.conn, a.repo.id) {
Ok(r) => r,
Err(e) => return ctx.app.internal(&e),
};
if policy::check_contents_write(&rules, &target_name, new_branch.is_some()).is_err() {
return denied(ctx);
}
let comps: Vec<&str> = w.path.split('/').collect();
Expand Down Expand Up @@ -387,7 +392,11 @@ fn run(ctx: &Ctx, a: &Access, row: &Repo, w: &Write) -> Resp {
return error(&ctx.app.base, 503, "the write took too long, try again");
}
// The linearization point of branch protection: just before the ref moves.
if policy::check_contents_write(&target_name, creating).is_err() {
let rules = match crate::protection::load(ctx.conn, a.repo.id) {
Ok(r) => r,
Err(e) => return ctx.app.internal(&e),
};
if policy::check_contents_write(&rules, &target_name, creating).is_err() {
return denied(ctx);
}
let failure = match gw::update_ref(git, &d, &full, &commit, &expected) {
Expand Down
40 changes: 39 additions & 1 deletion crates/forge/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,49 @@ const MIGRATIONS: &[&str] = &[
mode TEXT NOT NULL CHECK (mode IN ('read', 'write', 'admin')),
PRIMARY KEY (repo_id, user_id)
);",
// 3: branch protection rules (exact branch names; `lower_branch` is the Unicode-lowercased
// name the case-insensitive uniqueness and matching use; `status_check_contexts` is a JSON
// array, NULL when the request gave none) and commit statuses (latest per context is the
// highest id).
"CREATE TABLE branch_protections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
branch TEXT NOT NULL,
lower_branch TEXT NOT NULL,
enable_push INTEGER NOT NULL DEFAULT 0,
enable_status_check INTEGER NOT NULL DEFAULT 0,
status_check_contexts TEXT,
created_unix INTEGER NOT NULL,
updated_unix INTEGER NOT NULL,
UNIQUE (repo_id, lower_branch)
);
CREATE TABLE commit_statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
sha TEXT NOT NULL,
context TEXT NOT NULL,
state TEXT NOT NULL
CHECK (state IN ('pending', 'success', 'error', 'failure', 'warning', 'skipped')),
description TEXT NOT NULL DEFAULT '',
target_url TEXT NOT NULL DEFAULT '',
creator_id INTEGER NOT NULL REFERENCES users(id),
created_unix INTEGER NOT NULL,
updated_unix INTEGER NOT NULL
);
CREATE INDEX commit_statuses_by_sha ON commit_statuses (repo_id, sha, context, id);",
];

/// Every table the current schema creates; `/healthz` probes each, so a missing or unreadable one
/// (whatever the recorded version says) reads as not ready.
pub const TABLES: &[&str] = &["users", "org_members", "tokens", "repos", "collaborators"];
pub const TABLES: &[&str] = &[
"users",
"org_members",
"tokens",
"repos",
"collaborators",
"branch_protections",
"commit_statuses",
];

/// Database failures a caller reports to the operator.
#[derive(Debug, thiserror::Error)]
Expand Down
7 changes: 6 additions & 1 deletion crates/forge/src/gitproc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ pub struct Spec<'a> {
pub max_output: Option<usize>,
/// Longer deadline than the runner's default.
pub timeout: Option<Duration>,
/// Extra environment for this spawn, added after the pinned one (the `pre-receive` hook passes
/// the object directories of git's quarantine this way).
pub extra_env: &'a [(&'a str, &'a str)],
}

/// The runner.
Expand Down Expand Up @@ -217,7 +220,9 @@ impl Git {
.acquire_timeout(self.admission)
.ok_or(GitError::Busy)?;
let mut cmd = Command::new(program);
cmd.env_clear().envs(self.env(spec.identity));
cmd.env_clear()
.envs(self.env(spec.identity))
.envs(spec.extra_env.iter().copied());
if let Some(repo) = spec.repo {
cmd.arg(format!("--git-dir={}", repo.display()));
}
Expand Down
1 change: 1 addition & 0 deletions crates/forge/src/gitwrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ fn run(
identity: id,
max_output: Some(cap),
timeout: Some(left.min(Duration::from_secs(60))),
..Default::default()
})?;
if out.ok() {
Ok(out.stdout)
Expand Down
Loading
Loading