Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Repository resolution, authorization, and git dispatch for SSH.
Matt W2
Matt W3use std::path::{Path, PathBuf};
Matt W4use std::process::Stdio;
Matt W5
Matt W6use anyhow::Result;
Matt W7use df_auth::permissions::{self, AccessInputs, Viewer};
Matt W8use df_db::models::{OrgRole, RepoRole, Visibility};
Matt W9use russh::server::Session;
Matt W10use russh::ChannelId;
Matt W11use sqlx::PgPool;
Matt W12use tokio::io::AsyncReadExt;
Matt W13use uuid::Uuid;
Matt W14
Matt W15use crate::exec::Service;
Matt W16
Matt W17pub struct Resolved {
Matt W18 pub repo_id: Uuid,
Matt W19 pub can_push: bool,
Matt W20 /// Whether the repository is archived. Distinguished from `can_push` so the
Matt W21 /// client can be told *why* a push was refused.
Matt W22 pub archived: bool,
Matt W23}
Matt W24
Matt W25/// Resolve `owner/repo` and authorize the connected user.
Matt W26///
Matt W27/// Returns `Ok(None)` when the repository does not exist *or* the user may not
Matt W28/// see it — the caller must not distinguish the two (spec §9).
Matt W29pub async fn authorize(
Matt W30 db: &PgPool,
Matt W31 owner: &str,
Matt W32 name: &str,
Matt W33 user_id: Uuid,
Matt W34 _write: bool,
Matt W35) -> Result<Option<Resolved>> {
Matt W36 type Row = (
Matt W37 Uuid,
Matt W38 Visibility,
Matt W39 Option<Uuid>,
Matt W40 Option<RepoRole>,
Matt W41 Option<OrgRole>,
Matt W42 bool,
Matt W43 bool,
Matt W44 );
Matt W45 let row: Option<Row> =
Matt W46 sqlx::query_as(
Matt W47 r#"
Matt W48 SELECT r.id, r.visibility, r.owner_user_id,
Matt W49 c.role AS collaborator_role,
Matt W50 m.role AS org_role,
Matt W51 COALESCE(u.is_admin, false),
Matt W52 r.archived
Matt W53 FROM repos r
Matt W54 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W55 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W56 LEFT JOIN repo_collaborators c ON c.repo_id = r.id AND c.user_id = $3
Matt W57 LEFT JOIN org_members m ON m.org_id = r.owner_org_id AND m.user_id = $3
Matt W58 LEFT JOIN users u ON u.id = $3
Matt W59 WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2
Matt W60 "#,
Matt W61 )
Matt W62 .bind(owner)
Matt W63 .bind(name)
Matt W64 .bind(user_id)
Matt W65 .fetch_optional(db)
Matt W66 .await?;
Matt W67
Matt W68 let Some((repo_id, visibility, owner_user_id, collab, org, is_admin, archived)) = row else {
Matt W69 return Ok(None);
Matt W70 };
Matt W71
Matt W72 // The same resolver the web uses — one implementation, so SSH and HTTP
Matt W73 // cannot disagree about who may do what (spec §6).
Matt W74 let access = permissions::resolve(AccessInputs {
Matt W75 visibility,
Matt W76 viewer: Some(Viewer {
Matt W77 user_id,
Matt W78 is_site_admin: is_admin,
Matt W79 }),
Matt W80 owner_user_id,
Matt W81 collaborator_role: collab,
Matt W82 org_role: org,
Matt W83 });
Matt W84
Matt W85 if !access.can_read() {
Matt W86 return Ok(None);
Matt W87 }
Matt W88
Matt W89 Ok(Some(Resolved {
Matt W90 repo_id,
Matt W91 // An archived repository is read-only, on every transport. The settings
Matt W92 // page promises this, so it cannot be enforced only in the web UI.
Matt W93 can_push: access.can_push() && !archived,
Matt W94 archived,
Matt W95 }))
Matt W96}
Matt W97
Matt W98/// Sharded path, identical to `GitStore::repo_path`.
Matt W99pub fn repo_path(root: &str, id: Uuid) -> PathBuf {
Matt W100 let hex = id.simple().to_string();
Matt W101 Path::new(root).join(&hex[..2]).join(format!("{hex}.git"))
Matt W102}
Matt W103
Matt W104/// Spawn git, stream its output over the SSH channel, and return its stdin.
Matt W105///
Matt W106/// The caller keeps the returned stdin and feeds it every `data` frame the
Matt W107/// client sends, closing it on EOF. Without that the child blocks forever
Matt W108/// waiting for the pack — git's stdio is a two-way conversation, not a
Matt W109/// request/response.
Matt W110#[allow(clippy::too_many_arguments)]
Matt W111pub async fn run_git(
Matt W112 session: &mut Session,
Matt W113 channel: ChannelId,
Matt W114 service: Service,
Matt W115 dir: &Path,
Matt W116 db: &PgPool,
Matt W117 database_url: &str,
Matt W118 hook_binary: &str,
Matt W119 repo_id: Uuid,
Matt W120 user_id: Uuid,
Matt W121) -> Result<tokio::process::ChildStdin> {
Matt W122 let mut cmd = tokio::process::Command::new("git");
Matt W123 cmd.arg(service.git_subcommand())
Matt W124 .arg(".")
Matt W125 .current_dir(dir)
Matt W126 .stdin(Stdio::piped())
Matt W127 .stdout(Stdio::piped())
Matt W128 .stderr(Stdio::piped())
Matt W129 // A clean environment: nothing from the connection leaks into git, and
Matt W130 // the repository's own config cannot redirect what we run.
Matt W131 .env_clear()
Matt W132 .env("PATH", "/usr/bin:/bin:/usr/local/bin")
Matt W133 .env("HOME", "/nonexistent")
Matt W134 .env("GIT_CONFIG_NOSYSTEM", "1")
Matt W135 .env("GIT_TERMINAL_PROMPT", "0")
Matt W136 // So a git that is still running when the runtime tears the task down
Matt W137 // is killed rather than orphaned.
Matt W138 .kill_on_drop(true)
Matt W139 // Consumed by the pre-receive hook.
Matt W140 .env("DOGFOOD_REPO_ID", repo_id.to_string())
Matt W141 .env("DOGFOOD_PUSHER_ID", user_id.to_string())
Matt W142 .env("DOGFOOD_HOOK_BINARY", hook_binary)
Matt W143 .env("DATABASE_URL", database_url)
Matt W144 // The check is mandatory: a hook that cannot verify protection must
Matt W145 // refuse the push rather than wave it through.
Matt W146 .env("DOGFOOD_ENFORCE", "1");
Matt W147
Matt W148 let mut child = cmd.spawn()?;
Matt W149
Matt W150 let stdin = child.stdin.take().expect("piped");
Matt W151 let mut stdout = child.stdout.take().expect("piped");
Matt W152 let mut stderr = child.stderr.take().expect("piped");
Matt W153
Matt W154 let handle = session.handle();
Matt W155
Matt W156 let out_task = {
Matt W157 let handle = handle.clone();
Matt W158 tokio::spawn(async move {
Matt W159 let mut buf = vec![0u8; 32 * 1024];
Matt W160 // Once the client is gone we keep reading, discarding what we read.
Matt W161 // Stopping instead would leave git blocked writing into a pipe
Matt W162 // nobody drains — and since the reaper below waits on the child, it
Matt W163 // would wait forever. The pack is already being produced; draining
Matt W164 // it costs a copy and lets the process exit.
Matt W165 let mut client_gone = false;
Matt W166 loop {
Matt W167 match stdout.read(&mut buf).await {
Matt W168 Ok(0) | Err(_) => break,
Matt W169 Ok(n) => {
Matt W170 if client_gone {
Matt W171 continue;
Matt W172 }
Matt W173 if handle.data(channel, buf[..n].to_vec().into()).await.is_err() {
Matt W174 tracing::debug!("ssh client went away mid-stream; draining git");
Matt W175 client_gone = true;
Matt W176 }
Matt W177 }
Matt W178 }
Matt W179 }
Matt W180 })
Matt W181 };
Matt W182
Matt W183 let err_task = {
Matt W184 let handle = handle.clone();
Matt W185 tokio::spawn(async move {
Matt W186 let mut buf = vec![0u8; 8 * 1024];
Matt W187 // Drained past a dead channel for the same reason as stdout: a
Matt W188 // full stderr pipe blocks git just as thoroughly as a full stdout.
Matt W189 let mut client_gone = false;
Matt W190 loop {
Matt W191 match stderr.read(&mut buf).await {
Matt W192 Ok(0) | Err(_) => break,
Matt W193 Ok(n) => {
Matt W194 if client_gone {
Matt W195 continue;
Matt W196 }
Matt W197 // Stream 1 is stderr; this is where the pre-receive
Matt W198 // hook's rejection messages reach the user.
Matt W199 if handle
Matt W200 .extended_data(channel, 1, buf[..n].to_vec().into())
Matt W201 .await
Matt W202 .is_err()
Matt W203 {
Matt W204 client_gone = true;
Matt W205 }
Matt W206 }
Matt W207 }
Matt W208 }
Matt W209 })
Matt W210 };
Matt W211
Matt W212 // Reap the child and close the channel once it exits. This runs detached so
Matt W213 // `exec_request` can return and the handler can keep delivering client data
Matt W214 // to the stdin we hand back.
Matt W215 let db = db.clone();
Matt W216 tokio::spawn(async move {
Matt W217 let status = match child.wait().await {
Matt W218 Ok(s) => s,
Matt W219 Err(e) => {
Matt W220 tracing::error!("waiting for git failed: {e}");
Matt W221 let _ = handle.close(channel).await;
Matt W222 return;
Matt W223 }
Matt W224 };
Matt W225 let _ = out_task.await;
Matt W226 let _ = err_task.await;
Matt W227
Matt W228 // A push that git accepted has to be indexed, exactly as the HTTP
Matt W229 // transport does it — without this the objects land but the site never
Matt W230 // learns about them, so the push is invisible until somebody runs
Matt W231 // `dogfood-admin reindex` by hand.
Matt W232 //
Matt W233 // Only on success: a rejected push (a protected bookmark, say) wrote
Matt W234 // nothing to index. Failures here are logged and dropped rather than
Matt W235 // surfaced, because the objects are already durable and failing the
Matt W236 // client would make it retry a push that succeeded.
Matt W237 if service.is_write() && status.success() {
Matt W238 if let Err(e) = enqueue_index(&db, repo_id, Some(user_id)).await {
Matt W239 tracing::error!(repo = %repo_id, "enqueuing IndexPush failed: {e:#}");
Matt W240 }
Matt W241 if let Err(e) = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1")
Matt W242 .bind(repo_id)
Matt W243 .execute(&db)
Matt W244 .await
Matt W245 {
Matt W246 tracing::warn!(repo = %repo_id, "recording push time failed: {e}");
Matt W247 }
Matt W248 }
Matt W249
Matt W250 let code = status.code().unwrap_or(1) as u32;
Matt W251 let _ = handle.exit_status_request(channel, code).await;
Matt W252 let _ = handle.eof(channel).await;
Matt W253 let _ = handle.close(channel).await;
Matt W254 });
Matt W255
Matt W256 Ok(stdin)
Matt W257}
Matt W258
Matt W259/// Queue an indexing job for a repository.
Matt W260///
Matt W261/// Deliberately the same payload shape the HTTP transport enqueues — one job
Matt W262/// kind, one worker, whichever way the push arrived.
Matt W263async fn enqueue_index(db: &PgPool, repo_id: Uuid, pushed_by: Option<Uuid>) -> Result<()> {
Matt W264 sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
Matt W265 .bind(df_db::ids::new_id())
Matt W266 .bind(serde_json::json!({
Matt W267 "repo_id": repo_id,
Matt W268 "pushed_by": pushed_by,
Matt W269 }))
Matt W270 .execute(db)
Matt W271 .await?;
Matt W272 Ok(())
Matt W273}
Matt W274
Matt W275#[cfg(test)]
Matt W276mod tests {
Matt W277 use super::*;
Matt W278
Matt W279 #[test]
Matt W280 fn repo_paths_match_the_store_layout() {
Matt W281 // These two must never diverge, or SSH serves a different directory
Matt W282 // than the web does.
Matt W283 let id = Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap();
Matt W284 assert_eq!(
Matt W285 repo_path("/srv/repos", id),
Matt W286 PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git")
Matt W287 );
Matt W288 }
Matt W289
Matt W290 #[test]
Matt W291 fn repo_paths_never_escape_the_root() {
Matt W292 for _ in 0..50 {
Matt W293 let p = repo_path("/srv/repos", Uuid::now_v7());
Matt W294 assert!(p.starts_with("/srv/repos"));
Matt W295 assert!(!p.to_string_lossy().contains(".."));
Matt W296 }
Matt W297 }
Matt W298}

298 lines · Rust