forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
37.4 KiB
1//! Internal hook API, served over a unix socket at `data_dir/internal.sock`.
2//!
3//! Routes are registered explicitly with [`RouteFn`] instead of `#[route]`. The
4//! `#[route]` macro emits an `inventory::submit!` that `Router::builder().discover()`
5//! collects, and the public TCP router uses `.discover()`. A `RouteFn` passed to
6//! `.route(...)` never enters that inventory, so these endpoints are not reachable
7//! on the public listener.
8
9use std::collections::{BTreeMap, HashMap};
10use std::path::Path;
11use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
12
13use forge_core::protection::effective;
14use forge_core::{Repo, Role, Store};
15use forge_git::{GitCommand, GitEnv};
16use serde::{Deserialize, Serialize};
17use topcoat::context::{Cx, app_context};
18use topcoat::router::content::Json;
19use topcoat::router::error::{bad_request, forbidden};
20use topcoat::router::request::headers;
21use topcoat::router::response::IntoResponse;
22use topcoat::router::response::Response;
23use topcoat::router::{
24 Body, HeaderValue, Method, RouteFn, RouteFuture, Router, StatusCode, header, to_bytes,
25};
26
27use crate::admin_client::ADMIN_TOKEN_HEADER;
28use crate::audit::{self, Actor, Target};
29use crate::backup::scheduler::{run_backup_now, run_fsck_now};
30use crate::backup::{BackupOptions, Scope};
31use crate::post_update::after_ref_update;
32use crate::state::AppState;
33use forge_core::AuditAction;
34use forge_core::store::backup_runs::{RunKind, RunTrigger};
35
36/// Largest accepted hook request body.
37const MAX_BODY_BYTES: usize = 1024 * 1024;
38/// Header carrying `AppState::internal_token`.
39const TOKEN_HEADER: &str = "x-forge-internal-token";
40/// Environment variables git sets for the quarantine directory of a push.
41pub const QUARANTINE_VARS: [&str; 3] = [
42 "GIT_QUARANTINE_PATH",
43 "GIT_OBJECT_DIRECTORY",
44 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
45];
46
47/// One ref update from receive-pack.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct HookUpdate {
50 pub old: String,
51 pub new: String,
52 #[serde(rename = "ref")]
53 pub ref_name: String,
54}
55
56/// Body of `POST /hook/pre-receive` and `POST /hook/post-receive`.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct HookRequest {
59 pub repo_id: String,
60 pub actor_id: String,
61 pub protocol: String,
62 /// True when the push targets the repo's wiki repo instead of the code repo (see forge-git
63 /// `wiki.rs`). Wiki pushes skip the branch rules and the post-push work, and may only touch main.
64 #[serde(default)]
65 pub wiki: bool,
66 pub updates: Vec<HookUpdate>,
67 #[serde(default)]
68 pub quarantine_env: BTreeMap<String, String>,
69}
70
71/// Response of both hook endpoints. `message` is shown to the pusher on stderr.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct HookResponse {
74 pub allow: bool,
75 pub message: String,
76}
77
78impl HookResponse {
79 fn allow() -> Self {
80 Self {
81 allow: true,
82 message: String::new(),
83 }
84 }
85
86 fn deny(message: impl Into<String>) -> Self {
87 Self {
88 allow: false,
89 message: message.into(),
90 }
91 }
92}
93
94/// Router for the internal API. Serve it on the unix socket only.
95pub fn router(state: AppState) -> Router {
96 Router::builder()
97 .route(RouteFn::new(Method::POST, "/hook/pre-receive", pre_receive))
98 .route(RouteFn::new(
99 Method::POST,
100 "/hook/post-receive",
101 post_receive,
102 ))
103 .route(RouteFn::new(
104 Method::POST,
105 "/internal/admin/backup",
106 admin_backup,
107 ))
108 .route(RouteFn::new(
109 Method::POST,
110 "/internal/admin/fsck",
111 admin_fsck,
112 ))
113 .route(RouteFn::new(
114 Method::POST,
115 "/internal/admin/lfs-gc",
116 admin_lfs_gc,
117 ))
118 .app_context(state)
119 .build()
120}
121
122fn pre_receive(cx: &Cx, body: Body) -> RouteFuture<'_> {
123 Box::pin(async move { hook_endpoint(cx, body, Stage::PreReceive).await })
124}
125
126fn post_receive(cx: &Cx, body: Body) -> RouteFuture<'_> {
127 Box::pin(async move { hook_endpoint(cx, body, Stage::PostReceive).await })
128}
129
130#[derive(Clone, Copy)]
131enum Stage {
132 PreReceive,
133 PostReceive,
134}
135
136async fn hook_endpoint(
137 cx: &Cx,
138 body: Body,
139 stage: Stage,
140) -> topcoat::Result<topcoat::router::response::Response> {
141 let state = app_context::<AppState>(cx);
142 let presented = headers(cx)
143 .get(TOKEN_HEADER)
144 .and_then(|v| v.to_str().ok())
145 .unwrap_or("");
146 if !forge_core::token::constant_time_eq(presented.as_bytes(), state.internal_token.as_bytes()) {
147 return Err(forbidden().into());
148 }
149
150 let bytes = to_bytes(body, MAX_BODY_BYTES).await?;
151 let req: HookRequest = serde_json::from_slice(&bytes)
152 .map_err(|e| bad_request(format!("invalid hook request: {e}")))?;
153
154 let verdict = match stage {
155 Stage::PreReceive => match evaluate_pre_receive(&state.store, &state.git, &req).await {
156 Ok(verdict) => verdict,
157 Err(e) => {
158 // Fail closed: an internal error rejects the push.
159 tracing::error!(error = %e, repo_id = %req.repo_id, "pre-receive check failed");
160 HookResponse::deny("internal error while checking the push")
161 }
162 },
163 Stage::PostReceive if req.wiki => {
164 // A wiki push changes neither the code repo's refs nor its CI, pull requests or events.
165 // It still goes to the standby.
166 record_wiki_push(&state.store, &req).await;
167 crate::replication::enqueue_repo(state, &req.repo_id);
168 HookResponse::allow()
169 }
170 Stage::PostReceive => {
171 let verdict = record_events(&state.store, &req).await;
172 trigger_maintenance(&state.git, &req.repo_id);
173 after_push_refs(state, &req).await;
174 verdict
175 }
176 };
177 Json(verdict).into_response(cx)
178}
179
180fn admin_backup(cx: &Cx, body: Body) -> RouteFuture<'_> {
181 Box::pin(async move { admin_backup_endpoint(cx, body).await })
182}
183
184fn admin_fsck(cx: &Cx, body: Body) -> RouteFuture<'_> {
185 Box::pin(async move { admin_fsck_endpoint(cx, body).await })
186}
187
188fn admin_lfs_gc(cx: &Cx, body: Body) -> RouteFuture<'_> {
189 Box::pin(async move { admin_lfs_gc_endpoint(cx, body).await })
190}
191
192/// `POST /internal/admin/lfs-gc`. The body is ignored.
193async fn admin_lfs_gc_endpoint(cx: &Cx, _body: Body) -> topcoat::Result<Response> {
194 let state = app_context::<AppState>(cx);
195 if !admin_authorized(cx, state) {
196 return Err(forbidden().into());
197 }
198 Ok(
199 match crate::lfs::gc::gc(
200 &state.store,
201 &state.config.data_dir,
202 crate::lfs::gc::GC_MIN_AGE,
203 )
204 .await
205 {
206 Ok(summary) => admin_response(StatusCode::OK, serde_json::json!(summary)),
207 Err(e) => admin_error(&e),
208 },
209 )
210}
211
212/// Body of `POST /internal/admin/backup`. `out` is the directory that receives the set.
213#[derive(Deserialize)]
214struct AdminBackupRequest {
215 out: String,
216 #[serde(default)]
217 include_key: bool,
218 #[serde(default)]
219 scope: Option<Scope>,
220}
221
222/// Body of `POST /internal/admin/fsck`.
223#[derive(Deserialize)]
224struct AdminFsckRequest {
225 #[serde(default)]
226 full: bool,
227}
228
229/// Admin routes need the per-boot admin token. The socket itself is 0600 in the 0700 data dir.
230fn admin_authorized(cx: &Cx, state: &AppState) -> bool {
231 let presented = headers(cx)
232 .get(ADMIN_TOKEN_HEADER)
233 .and_then(|v| v.to_str().ok())
234 .unwrap_or("");
235 forge_core::token::constant_time_eq(presented.as_bytes(), state.admin_token.as_bytes())
236}
237
238async fn admin_backup_endpoint(cx: &Cx, body: Body) -> topcoat::Result<Response> {
239 let state = app_context::<AppState>(cx);
240 if !admin_authorized(cx, state) {
241 return Err(forbidden().into());
242 }
243 let bytes = to_bytes(body, MAX_BODY_BYTES).await?;
244 let req: AdminBackupRequest = serde_json::from_slice(&bytes)
245 .map_err(|e| bad_request(format!("invalid backup request: {e}")))?;
246 let out = match std::path::absolute(&req.out) {
247 Ok(out) => out,
248 Err(e) => return Ok(admin_error(&forge_core::Error::Io(e))),
249 };
250 let guard = match state.jobs.claim(RunKind::Backup) {
251 Ok(guard) => guard,
252 Err(e) => return Ok(admin_error(&e)),
253 };
254 let target_name = out.to_string_lossy().into_owned();
255 audit::record(
256 &state.store,
257 None,
258 Actor::Cli,
259 AuditAction::BackupStarted,
260 Target {
261 kind: "backup",
262 id: "",
263 name: &target_name,
264 },
265 serde_json::json!({ "trigger": "cli" }),
266 )
267 .await;
268 let opts = BackupOptions {
269 out,
270 include_key: req.include_key,
271 scope: req.scope.unwrap_or(Scope::All),
272 };
273 let result = run_backup_now(state, guard, RunTrigger::Cli, opts, false).await;
274 Ok(match result {
275 Ok(summary) => admin_response(StatusCode::OK, serde_json::json!(summary)),
276 Err(e) => admin_error(&e),
277 })
278}
279
280async fn admin_fsck_endpoint(cx: &Cx, body: Body) -> topcoat::Result<Response> {
281 let state = app_context::<AppState>(cx);
282 if !admin_authorized(cx, state) {
283 return Err(forbidden().into());
284 }
285 let bytes = to_bytes(body, MAX_BODY_BYTES).await?;
286 let req: AdminFsckRequest = if bytes.is_empty() {
287 AdminFsckRequest { full: false }
288 } else {
289 serde_json::from_slice(&bytes)
290 .map_err(|e| bad_request(format!("invalid fsck request: {e}")))?
291 };
292 let guard = match state.jobs.claim(RunKind::Fsck) {
293 Ok(guard) => guard,
294 Err(e) => return Ok(admin_error(&e)),
295 };
296 audit::record(
297 &state.store,
298 None,
299 Actor::Cli,
300 AuditAction::FsckStarted,
301 Target {
302 kind: "fsck",
303 id: "",
304 name: "",
305 },
306 serde_json::json!({ "trigger": "cli", "full": req.full }),
307 )
308 .await;
309 let result = run_fsck_now(state, guard, RunTrigger::Cli, req.full).await;
310 Ok(match result {
311 Ok(summary) => admin_response(StatusCode::OK, serde_json::json!(summary)),
312 Err(e) => admin_error(&e),
313 })
314}
315
316/// A JSON answer with the given status.
317fn admin_response(status: StatusCode, value: serde_json::Value) -> Response {
318 let mut res = Response::new(Body::from(value.to_string()));
319 *res.status_mut() = status;
320 res.headers_mut().insert(
321 header::CONTENT_TYPE,
322 HeaderValue::from_static("application/json"),
323 );
324 res
325}
326
327/// Maps a failed admin action to a status and a `{"error": ...}` body.
328fn admin_error(e: &forge_core::Error) -> Response {
329 let status = match e {
330 forge_core::Error::Conflict(_) => StatusCode::CONFLICT,
331 forge_core::Error::Invalid(_) | forge_core::Error::Config(_) => StatusCode::BAD_REQUEST,
332 forge_core::Error::Forbidden(_) => StatusCode::FORBIDDEN,
333 forge_core::Error::NotFound => StatusCode::NOT_FOUND,
334 _ => StatusCode::INTERNAL_SERVER_ERROR,
335 };
336 let message = match e {
337 forge_core::Error::Conflict(text) => text.clone(),
338 other => other.to_string(),
339 };
340 admin_response(status, serde_json::json!({ "error": message }))
341}
342
343async fn record_events(store: &Store, req: &HookRequest) -> HookResponse {
344 let mut failures = Vec::new();
345 for update in &req.updates {
346 let recorded = store
347 .record_push_event(
348 &req.repo_id,
349 &req.actor_id,
350 &update.ref_name,
351 &update.old,
352 &update.new,
353 &req.protocol,
354 )
355 .await;
356 if let Err(e) = recorded {
357 tracing::error!(error = %e, repo_id = %req.repo_id, ref_name = %update.ref_name, "failed to record push event");
358 failures.push(format!(
359 "warning: could not record push to {:?}",
360 update.ref_name
361 ));
362 }
363 }
364 // post-receive cannot reject a push, so the verdict is always allow.
365 HookResponse {
366 allow: true,
367 message: failures.join("\n"),
368 }
369}
370
371/// Repos with post-push maintenance in flight. The value is true when another push
372/// arrived while the run was active, so one rerun is due.
373static MAINTENANCE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
374
375fn maintenance_state() -> MutexGuard<'static, HashMap<String, bool>> {
376 MAINTENANCE
377 .get_or_init(|| Mutex::new(HashMap::new()))
378 .lock()
379 .unwrap_or_else(PoisonError::into_inner)
380}
381
382/// Marks `repo_id` in flight. Returns true when the caller must start the worker; otherwise
383/// the running worker is flagged to run once more.
384fn claim_maintenance(map: &mut HashMap<String, bool>, repo_id: &str) -> bool {
385 match map.get_mut(repo_id) {
386 Some(dirty) => {
387 *dirty = true;
388 false
389 }
390 None => {
391 map.insert(repo_id.to_owned(), false);
392 true
393 }
394 }
395}
396
397/// Called after a run. Returns true when a rerun is due (the entry stays claimed);
398/// otherwise releases the repo.
399fn finish_maintenance(map: &mut HashMap<String, bool>, repo_id: &str) -> bool {
400 let rerun = map
401 .get_mut(repo_id)
402 .map(|dirty| std::mem::replace(dirty, false));
403 if rerun == Some(true) {
404 return true;
405 }
406 map.remove(repo_id);
407 false
408}
409
410/// Starts `maintenance::after_push` for the repo in the background, coalescing concurrent calls
411/// per repo. Failures are logged, never returned, so the push response is not failed. Called once
412/// per push by the post-receive hook, and by the web merge route after it updates a branch.
413pub fn trigger_maintenance(git: &Arc<GitEnv>, repo_id: &str) {
414 let repo_dir = match forge_git::repo::repo_path(&git.data_dir, repo_id) {
415 Ok(dir) => dir,
416 Err(e) => {
417 tracing::error!(error = %e, repo_id = %repo_id, "skipping post-push maintenance");
418 return;
419 }
420 };
421 if !claim_maintenance(&mut maintenance_state(), repo_id) {
422 return;
423 }
424 let git = Arc::clone(git);
425 let repo_id = repo_id.to_owned();
426 tokio::spawn(async move {
427 loop {
428 if let Err(e) = forge_git::maintenance::after_push(&git, &repo_dir).await {
429 tracing::error!(error = %e, repo_id = %repo_id, "post-push maintenance failed");
430 }
431 let rerun = finish_maintenance(&mut maintenance_state(), &repo_id);
432 if !rerun {
433 break;
434 }
435 }
436 });
437}
438
439/// Runs `post_update::after_ref_update` for every ref the push updated. Errors are logged; the
440/// push itself is already accepted at this point.
441async fn after_push_refs(state: &AppState, req: &HookRequest) {
442 let repo: Repo = match state.store.repo_by_id(&req.repo_id).await {
443 Ok(Some(repo)) => repo,
444 Ok(None) => return,
445 Err(e) => {
446 tracing::error!(error = %e, repo_id = %req.repo_id, "post-update: repo lookup failed");
447 return;
448 }
449 };
450 for update in &req.updates {
451 if let Err(e) = after_ref_update(
452 state,
453 &repo,
454 &req.actor_id,
455 &update.ref_name,
456 &update.old,
457 &update.new,
458 )
459 .await
460 {
461 tracing::error!(error = %e, repo_id = %req.repo_id, ref_name = %update.ref_name, "post-update failed");
462 }
463 }
464}
465
466/// Writes a `wiki_pushed` audit entry for an accepted wiki push. The pusher is looked up by id; a
467/// push with no known user (a CI job cannot push) is recorded as anonymous.
468async fn record_wiki_push(store: &Store, req: &HookRequest) {
469 let repo = match store.repo_by_id(&req.repo_id).await {
470 Ok(Some(repo)) => repo,
471 Ok(None) => return,
472 Err(e) => {
473 tracing::error!(error = %e, repo_id = %req.repo_id, "could not load repo for wiki audit");
474 return;
475 }
476 };
477 let user = match store.user_by_id(&req.actor_id).await {
478 Ok(user) => user,
479 Err(e) => {
480 tracing::error!(error = %e, actor_id = %req.actor_id, "could not load pusher for wiki audit");
481 None
482 }
483 };
484 let actor = match user.as_ref() {
485 Some(user) => Actor::User(user),
486 None => Actor::Anonymous,
487 };
488 let updates: Vec<serde_json::Value> = req
489 .updates
490 .iter()
491 .map(|u| serde_json::json!({ "ref": u.ref_name, "old": u.old, "new": u.new }))
492 .collect();
493 audit::record(
494 store,
495 None,
496 actor,
497 AuditAction::WikiPushed,
498 Target {
499 kind: "repo",
500 id: &repo.id,
501 name: &format!("{}/{}", repo.owner_name, repo.name),
502 },
503 serde_json::json!({ "updates": updates }),
504 )
505 .await;
506}
507
508/// Decides whether a push may proceed. `Ok(deny)` is a policy decision; `Err` is an
509/// internal failure (the caller turns it into a deny).
510pub async fn evaluate_pre_receive(
511 store: &Store,
512 git: &GitEnv,
513 req: &HookRequest,
514) -> forge_core::Result<HookResponse> {
515 let Some(repo) = store.repo_by_id(&req.repo_id).await? else {
516 return Ok(HookResponse::deny("repository not found"));
517 };
518 if repo.archived {
519 return Ok(HookResponse::deny("repository is archived"));
520 }
521 if req.wiki {
522 if !store.wiki_enabled(&repo.id).await? {
523 return Ok(HookResponse::deny("the wiki is disabled"));
524 }
525 } else if store.pull_mirror(&repo.id).await?.is_some() {
526 // A pull mirror's refs come from its remote; a push here would be overwritten by the next sync.
527 // Mirrors do not mirror wikis, so wiki pushes are not refused for this.
528 return Ok(HookResponse::deny("this repository is a pull mirror"));
529 }
530 let repo_dir = if req.wiki {
531 forge_git::repo::wiki_path(&git.data_dir, &repo.id)?
532 } else {
533 forge_git::repo::repo_path(&git.data_dir, &repo.id)?
534 };
535 // Loaded once per push; every branch update below is checked against the same rules. Wiki
536 // pushes skip the rules: the wiki has no protected branches, only its main branch.
537 let rules = if req.wiki {
538 Vec::new()
539 } else {
540 store.list_branch_rules(&repo.id).await?
541 };
542
543 for update in &req.updates {
544 if !is_valid_ref_name(&update.ref_name) {
545 return Ok(HookResponse::deny(format!(
546 "invalid ref name: {:?}",
547 update.ref_name
548 )));
549 }
550 if !is_valid_oid(&update.old) || !is_valid_oid(&update.new) {
551 return Ok(HookResponse::deny(format!(
552 "invalid object id in update to {:?}",
553 update.ref_name
554 )));
555 }
556 if req.wiki {
557 if update.ref_name != "refs/heads/main" {
558 return Ok(HookResponse::deny(
559 "the wiki only accepts changes to its main branch",
560 ));
561 }
562 if is_zero_oid(&update.new) {
563 return Ok(HookResponse::deny(
564 "the wiki's main branch cannot be deleted",
565 ));
566 }
567 continue;
568 }
569 let Some(branch) = update.ref_name.strip_prefix("refs/heads/") else {
570 continue;
571 };
572 let protection = effective(&rules, &repo.default_branch, branch);
573 if protection.restrict_push && !may_push_directly(store, &repo, &req.actor_id).await? {
574 return Ok(HookResponse::deny(format!(
575 "branch '{branch}' only accepts changes through pull requests"
576 )));
577 }
578 if is_zero_oid(&update.new) {
579 if !protection.allow_deletion {
580 return Ok(HookResponse::deny(format!(
581 "deleting protected branch '{branch}' is not allowed"
582 )));
583 }
584 continue;
585 }
586 if is_zero_oid(&update.old) || protection.allow_force_push {
587 continue;
588 }
589 match drops_commits(git, &repo_dir, req, update).await {
590 Ok(false) => {}
591 Ok(true) => {
592 return Ok(HookResponse::deny(format!(
593 "force-push to protected branch '{branch}' is not allowed"
594 )));
595 }
596 Err(e) => {
597 return Ok(HookResponse::deny(format!(
598 "could not verify the update to '{branch}': {e}"
599 )));
600 }
601 }
602 }
603 Ok(HookResponse::allow())
604}
605
606/// Whether `actor_id` may push straight to a `restrict_push` branch: an instance admin, the owner
607/// of a person-owned repo, or someone whose effective role is Admin (a collaborator Admin, or an
608/// org owner or admin team on an org-owned repo). Pull request merges do not go through here.
609async fn may_push_directly(store: &Store, repo: &Repo, actor_id: &str) -> forge_core::Result<bool> {
610 let Some(actor) = store.user_by_id(actor_id).await? else {
611 return Ok(false);
612 };
613 if actor.is_admin || actor.id == repo.owner_id {
614 return Ok(true);
615 }
616 let collaborator = store.collaborator_role(&repo.id, actor_id).await?;
617 let org = store
618 .org_repo_role(&repo.owner_id, actor_id, &repo.id)
619 .await?;
620 Ok(forge_core::authz::max_role(collaborator, org) == Some(Role::Admin))
621}
622
623/// True when `old` has commits that `new` does not contain, i.e. the update
624/// is not a fast-forward. Runs `git rev-list --max-count=1 <old> ^<new>` with the
625/// quarantine environment so that objects received in this push are visible.
626async fn drops_commits(
627 git: &GitEnv,
628 repo_dir: &Path,
629 req: &HookRequest,
630 update: &HookUpdate,
631) -> forge_git::Result<bool> {
632 let excluded = format!("^{}", update.new);
633 let mut cmd = GitCommand::new(git, "rev-list")
634 .git_dir(repo_dir)
635 .arg("--max-count=1")
636 .user_revs(&[update.old.as_str(), excluded.as_str()])?;
637 for key in QUARANTINE_VARS {
638 if let Some(value) = req.quarantine_env.get(key) {
639 cmd = cmd.env(key, value);
640 }
641 }
642 let stdout = cmd.output().await?;
643 Ok(!stdout.is_empty())
644}
645
646/// Accepts `refs/...` names without `..`, without a `-`-prefixed component, and
647/// without control characters.
648pub fn is_valid_ref_name(name: &str) -> bool {
649 let Some(rest) = name.strip_prefix("refs/") else {
650 return false;
651 };
652 !rest.is_empty()
653 && !name.contains("..")
654 && !name.chars().any(char::is_control)
655 && !name.split('/').any(|component| component.starts_with('-'))
656}
657
658/// True for the all-zero object id (40 or 64 hex digits), which git uses for
659/// creation (`old`) and deletion (`new`).
660pub fn is_zero_oid(oid: &str) -> bool {
661 matches!(oid.len(), 40 | 64) && oid.bytes().all(|b| b == b'0')
662}
663
664/// True for a SHA-1 or SHA-256 object id: 40 or 64 hex digits.
665pub fn is_valid_oid(oid: &str) -> bool {
666 matches!(oid.len(), 40 | 64) && oid.bytes().all(|b| b.is_ascii_hexdigit())
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use forge_core::protection::RuleFlags;
673 use std::path::PathBuf;
674 use topcoat::router::RouterBuilderDiscoverExt;
675
676 #[test]
677 fn ref_names_must_live_under_refs() {
678 assert!(is_valid_ref_name("refs/heads/main"));
679 assert!(is_valid_ref_name("refs/tags/v1.0"));
680 assert!(!is_valid_ref_name("HEAD"));
681 assert!(!is_valid_ref_name("heads/main"));
682 assert!(!is_valid_ref_name("refs/"));
683 }
684
685 #[test]
686 fn ref_names_reject_dotdot_dash_components_and_control_chars() {
687 assert!(!is_valid_ref_name("refs/heads/a..b"));
688 assert!(!is_valid_ref_name("refs/heads/-evil"));
689 assert!(!is_valid_ref_name("refs/-evil/main"));
690 assert!(is_valid_ref_name("refs/heads/a-b"));
691 assert!(!is_valid_ref_name("refs/heads/a\nb"));
692 assert!(!is_valid_ref_name("refs/heads/a\0b"));
693 assert!(!is_valid_ref_name("refs/heads/a\x7fb"));
694 }
695
696 #[test]
697 fn zero_oid_detection() {
698 assert!(is_zero_oid(&"0".repeat(40)));
699 assert!(is_zero_oid(&"0".repeat(64)));
700 assert!(!is_zero_oid(&"0".repeat(39)));
701 assert!(!is_zero_oid(&format!("{}1", "0".repeat(39))));
702 assert!(!is_zero_oid(""));
703 }
704
705 #[test]
706 fn oid_validation() {
707 assert!(is_valid_oid(&"a".repeat(40)));
708 assert!(is_valid_oid(&"F".repeat(64)));
709 assert!(is_valid_oid(&"0".repeat(40)));
710 assert!(!is_valid_oid(&"g".repeat(40)));
711 assert!(!is_valid_oid(&"a".repeat(41)));
712 assert!(!is_valid_oid("--all"));
713 }
714
715 #[test]
716 fn maintenance_claims_coalesce_per_repo() {
717 let mut map = HashMap::new();
718 // First push starts a worker; pushes during the run only flag a rerun.
719 assert!(claim_maintenance(&mut map, "r1"));
720 assert!(!claim_maintenance(&mut map, "r1"));
721 assert!(!claim_maintenance(&mut map, "r1"));
722 // Other repos are independent.
723 assert!(claim_maintenance(&mut map, "r2"));
724 // One rerun is due after the first run, then the repo is released.
725 assert!(finish_maintenance(&mut map, "r1"));
726 assert!(!finish_maintenance(&mut map, "r1"));
727 assert!(!map.contains_key("r1"));
728 assert!(claim_maintenance(&mut map, "r1"));
729 assert!(!finish_maintenance(&mut map, "r2"));
730 }
731
732 fn git(dir: &Path, args: &[&str]) -> String {
733 let out = std::process::Command::new("git")
734 .arg("-C")
735 .arg(dir)
736 .args(args)
737 .env("GIT_AUTHOR_NAME", "test")
738 .env("GIT_AUTHOR_EMAIL", "test@example.com")
739 .env("GIT_COMMITTER_NAME", "test")
740 .env("GIT_COMMITTER_EMAIL", "test@example.com")
741 .output()
742 .expect("git runs");
743 assert!(
744 out.status.success(),
745 "git {args:?} failed: {}",
746 String::from_utf8_lossy(&out.stderr)
747 );
748 String::from_utf8_lossy(&out.stdout).trim().to_string()
749 }
750
751 fn commit(work: &Path, contents: &str) -> String {
752 std::fs::write(work.join("file.txt"), contents).expect("write file");
753 git(work, &["add", "file.txt"]);
754 git(work, &["commit", "-m", contents]);
755 git(work, &["rev-parse", "HEAD"])
756 }
757
758 #[tokio::test]
759 async fn admin_routes_reject_a_missing_or_wrong_token() {
760 let dir = tempfile::tempdir().expect("tempdir");
761 let state = crate::test_support::test_state(dir.path()).await;
762 let router = router(state);
763 for path in ["/internal/admin/backup", "/internal/admin/fsck"] {
764 for token in [None, Some("wrong")] {
765 let mut builder = topcoat::router::request::Request::builder()
766 .method("POST")
767 .uri(path)
768 .header("content-type", "application/json");
769 if let Some(token) = token {
770 builder = builder.header(ADMIN_TOKEN_HEADER, token);
771 }
772 let req = builder
773 .body(Body::from(r#"{"out":"/nonexistent/forge-test"}"#))
774 .expect("request");
775 let res = router.handle(req).await;
776 assert_eq!(res.status(), 403, "{path} with token {token:?}");
777 }
778 }
779 // The right token gets through: an fsck of an instance with no repos is clean.
780 let req = topcoat::router::request::Request::builder()
781 .method("POST")
782 .uri("/internal/admin/fsck")
783 .header("content-type", "application/json")
784 .header(ADMIN_TOKEN_HEADER, crate::test_support::ADMIN_TOKEN)
785 .body(Body::from("{}"))
786 .expect("request");
787 let res = router.handle(req).await;
788 assert_eq!(res.status(), 200);
789 }
790
791 #[tokio::test]
792 async fn hook_routes_are_not_collected_by_discover() {
793 // The public router uses `.discover()`. Hook routes are plain RouteFns and must not be in it.
794 let public = Router::builder().discover().build();
795 for path in ["/hook/pre-receive", "/hook/post-receive"] {
796 let req = topcoat::router::request::Request::builder()
797 .method("POST")
798 .uri(path)
799 .body(Body::empty())
800 .expect("request");
801 let res = public.handle(req).await;
802 assert_eq!(
803 res.status(),
804 404,
805 "{path} is reachable on the discover router"
806 );
807 }
808 }
809
810 #[tokio::test]
811 async fn pre_receive_restrict_push_allows_only_repo_admins() {
812 let tmp = tempfile::tempdir().expect("tempdir");
813 let store = Store::open(&tmp.path().join("forge.db"))
814 .await
815 .expect("store");
816 let owner = store
817 .create_user("alice", None, "unused", false)
818 .await
819 .expect("owner");
820 let writer = store
821 .create_user("bob", None, "unused", false)
822 .await
823 .expect("writer");
824 let admin = store
825 .create_user("carol", None, "unused", false)
826 .await
827 .expect("admin");
828 let repo = store
829 .create_repo(
830 &owner.id,
831 "proj",
832 "",
833 forge_core::Visibility::Private,
834 "main",
835 )
836 .await
837 .expect("repo");
838 store
839 .set_collaborator(&repo.id, &writer.id, Role::Write)
840 .await
841 .expect("writer role");
842 store
843 .set_collaborator(&repo.id, &admin.id, Role::Admin)
844 .await
845 .expect("admin role");
846 let git_env = GitEnv {
847 git_bin: PathBuf::from("git"),
848 data_dir: tmp.path().to_path_buf(),
849 };
850 let bare = forge_git::repo::repo_path(&git_env.data_dir, &repo.id).expect("repo path");
851 forge_git::repo::init_bare(&git_env, &bare, "main")
852 .await
853 .expect("init bare");
854 let bare_str = bare.to_str().expect("utf8 path");
855 let work = tmp.path().join("work");
856 git(tmp.path(), &["init", "-b", "main", "work"]);
857 let first = commit(&work, "one");
858 git(&work, &["push", bare_str, "main"]);
859 let second = commit(&work, "two");
860 git(&work, &["push", bare_str, "main"]);
861
862 store
863 .upsert_branch_rule(
864 &repo.id,
865 "main",
866 RuleFlags {
867 restrict_push: true,
868 ..RuleFlags::default()
869 },
870 )
871 .await
872 .expect("upsert rule");
873
874 let request = |actor: &str, old: &str, new: &str| HookRequest {
875 repo_id: repo.id.clone(),
876 actor_id: actor.into(),
877 protocol: "http".into(),
878 wiki: false,
879 updates: vec![HookUpdate {
880 old: old.into(),
881 new: new.into(),
882 ref_name: "refs/heads/main".into(),
883 }],
884 quarantine_env: BTreeMap::new(),
885 };
886
887 // A Write collaborator's direct fast-forward is refused.
888 let refused = evaluate_pre_receive(&store, &git_env, &request(&writer.id, &first, &second))
889 .await
890 .expect("evaluate");
891 assert!(!refused.allow);
892 assert_eq!(
893 refused.message,
894 "branch 'main' only accepts changes through pull requests"
895 );
896 // So is creating the branch.
897 let created = evaluate_pre_receive(
898 &store,
899 &git_env,
900 &request(&writer.id, &"0".repeat(40), &second),
901 )
902 .await
903 .expect("evaluate");
904 assert!(!created.allow);
905
906 // Repo admins (Admin collaborators and the owner) may still push.
907 for actor in [&admin.id, &owner.id] {
908 let ok = evaluate_pre_receive(&store, &git_env, &request(actor, &first, &second))
909 .await
910 .expect("evaluate");
911 assert_eq!(ok, HookResponse::allow());
912 }
913 }
914
915 #[tokio::test]
916 async fn pre_receive_rejects_pushes_to_a_pull_mirror() {
917 let tmp = tempfile::tempdir().expect("tempdir");
918 let store = Store::open(&tmp.path().join("forge.db"))
919 .await
920 .expect("store");
921 let owner = store
922 .create_user("alice", None, "unused", false)
923 .await
924 .expect("user");
925 let repo = store
926 .create_repo(
927 &owner.id,
928 "mirror",
929 "",
930 forge_core::Visibility::Public,
931 "main",
932 )
933 .await
934 .expect("repo");
935 let git_env = GitEnv {
936 git_bin: "git".into(),
937 data_dir: tmp.path().to_path_buf(),
938 };
939 let bare = forge_git::repo::repo_path(&git_env.data_dir, &repo.id).expect("repo path");
940 forge_git::repo::init_bare(&git_env, &bare, "main")
941 .await
942 .expect("init bare");
943 let request = HookRequest {
944 repo_id: repo.id.clone(),
945 actor_id: owner.id.clone(),
946 protocol: "http".into(),
947 wiki: false,
948 updates: vec![HookUpdate {
949 old: "0".repeat(40),
950 new: "1".repeat(40),
951 ref_name: "refs/heads/main".into(),
952 }],
953 quarantine_env: BTreeMap::new(),
954 };
955 // Before the pull mirror exists, the push is not refused for that reason.
956 let before = evaluate_pre_receive(&store, &git_env, &request)
957 .await
958 .expect("evaluate");
959 assert_ne!(before.message, "this repository is a pull mirror");
960
961 store
962 .create_mirror(&forge_core::mirrors::NewMirror {
963 repo_id: &repo.id,
964 direction: forge_core::mirrors::MirrorDirection::Pull,
965 url: "https://example.com/upstream.git",
966 username: "",
967 secret: None,
968 interval_minutes: 480,
969 })
970 .await
971 .expect("pull mirror");
972 let refused = evaluate_pre_receive(&store, &git_env, &request)
973 .await
974 .expect("evaluate");
975 assert!(!refused.allow);
976 assert_eq!(refused.message, "this repository is a pull mirror");
977 }
978
979 #[tokio::test]
980 async fn pre_receive_detects_non_fast_forward_on_default_branch() {
981 let tmp = tempfile::tempdir().expect("tempdir");
982 let store = Store::open(&tmp.path().join("forge.db"))
983 .await
984 .expect("store");
985 let owner = store
986 .create_user("alice", None, "unused", false)
987 .await
988 .expect("user");
989 let repo = store
990 .create_repo(
991 &owner.id,
992 "proj",
993 "",
994 forge_core::Visibility::Private,
995 "main",
996 )
997 .await
998 .expect("repo");
999 let git_env = GitEnv {
1000 git_bin: PathBuf::from("git"),
1001 data_dir: tmp.path().to_path_buf(),
1002 };
1003
1004 // Bare repo at the path forge would use for this repo id.
1005 let bare = forge_git::repo::repo_path(&git_env.data_dir, &repo.id).expect("repo path");
1006 forge_git::repo::init_bare(&git_env, &bare, "main")
1007 .await
1008 .expect("init bare");
1009 let bare_str = bare.to_str().expect("utf8 path");
1010
1011 // Two commits on main, then a divergent commit on a side branch.
1012 let work = tmp.path().join("work");
1013 git(tmp.path(), &["init", "-b", "main", "work"]);
1014 let first = commit(&work, "one");
1015 git(&work, &["push", bare_str, "main"]);
1016 let second = commit(&work, "two");
1017 git(&work, &["push", bare_str, "main"]);
1018 git(&work, &["reset", "--hard", &first]);
1019 let divergent = commit(&work, "three");
1020 git(
1021 &work,
1022 &["push", bare_str, &format!("{divergent}:refs/heads/side")],
1023 );
1024
1025 let request = |old: &str, new: &str| HookRequest {
1026 repo_id: repo.id.clone(),
1027 actor_id: owner.id.clone(),
1028 protocol: "http".into(),
1029 wiki: false,
1030 updates: vec![HookUpdate {
1031 old: old.into(),
1032 new: new.into(),
1033 ref_name: "refs/heads/main".into(),
1034 }],
1035 quarantine_env: BTreeMap::new(),
1036 };
1037
1038 // Fast-forward: allowed.
1039 let ff = evaluate_pre_receive(&store, &git_env, &request(&first, &second))
1040 .await
1041 .expect("evaluate");
1042 assert_eq!(ff, HookResponse::allow());
1043
1044 // Non-fast-forward: denied.
1045 let forced = evaluate_pre_receive(&store, &git_env, &request(&second, &divergent))
1046 .await
1047 .expect("evaluate");
1048 assert!(!forced.allow);
1049 assert_eq!(
1050 forced.message,
1051 "force-push to protected branch 'main' is not allowed"
1052 );
1053
1054 // Deleting the default branch: denied.
1055 let deleted = evaluate_pre_receive(&store, &git_env, &request(&second, &"0".repeat(40)))
1056 .await
1057 .expect("evaluate");
1058 assert!(!deleted.allow);
1059 assert_eq!(
1060 deleted.message,
1061 "deleting protected branch 'main' is not allowed"
1062 );
1063
1064 // A rule that allows force pushes lifts only that check; deletion stays denied.
1065 store
1066 .upsert_branch_rule(&repo.id, "main", RuleFlags::basic(true, false))
1067 .await
1068 .expect("upsert rule");
1069 let forced_ok = evaluate_pre_receive(&store, &git_env, &request(&second, &divergent))
1070 .await
1071 .expect("evaluate");
1072 assert_eq!(forced_ok, HookResponse::allow());
1073 let deleted_again =
1074 evaluate_pre_receive(&store, &git_env, &request(&second, &"0".repeat(40)))
1075 .await
1076 .expect("evaluate");
1077 assert_eq!(
1078 deleted_again.message,
1079 "deleting protected branch 'main' is not allowed"
1080 );
1081 }
1082}