| 1 | //! Repository resolution, authorization, and git dispatch for SSH. |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | use std::process::Stdio; |
| 5 | |
| 6 | use anyhow::Result; |
| 7 | use df_auth::permissions::{self, AccessInputs, Viewer}; |
| 8 | use df_db::models::{OrgRole, RepoRole, Visibility}; |
| 9 | use russh::server::Session; |
| 10 | use russh::ChannelId; |
| 11 | use sqlx::PgPool; |
| 12 | use tokio::io::AsyncReadExt; |
| 13 | use uuid::Uuid; |
| 14 | |
| 15 | use crate::exec::Service; |
| 16 | |
| 17 | pub struct Resolved { |
| 18 | pub repo_id: Uuid, |
| 19 | pub can_push: bool, |
| 20 | /// Whether the repository is archived. Distinguished from `can_push` so the |
| 21 | /// client can be told *why* a push was refused. |
| 22 | pub archived: bool, |
| 23 | } |
| 24 | |
| 25 | /// Resolve `owner/repo` and authorize the connected user. |
| 26 | /// |
| 27 | /// Returns `Ok(None)` when the repository does not exist *or* the user may not |
| 28 | /// see it — the caller must not distinguish the two (spec §9). |
| 29 | pub async fn authorize( |
| 30 | db: &PgPool, |
| 31 | owner: &str, |
| 32 | name: &str, |
| 33 | user_id: Uuid, |
| 34 | _write: bool, |
| 35 | ) -> Result<Option<Resolved>> { |
| 36 | type Row = ( |
| 37 | Uuid, |
| 38 | Visibility, |
| 39 | Option<Uuid>, |
| 40 | Option<RepoRole>, |
| 41 | Option<OrgRole>, |
| 42 | bool, |
| 43 | bool, |
| 44 | ); |
| 45 | let row: Option<Row> = |
| 46 | sqlx::query_as( |
| 47 | r#" |
| 48 | SELECT r.id, r.visibility, r.owner_user_id, |
| 49 | c.role AS collaborator_role, |
| 50 | m.role AS org_role, |
| 51 | COALESCE(u.is_admin, false), |
| 52 | r.archived |
| 53 | FROM repos r |
| 54 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 55 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 56 | LEFT JOIN repo_collaborators c ON c.repo_id = r.id AND c.user_id = $3 |
| 57 | LEFT JOIN org_members m ON m.org_id = r.owner_org_id AND m.user_id = $3 |
| 58 | LEFT JOIN users u ON u.id = $3 |
| 59 | WHERE COALESCE(ou.handle, og.handle) = $1 AND r.name = $2 |
| 60 | "#, |
| 61 | ) |
| 62 | .bind(owner) |
| 63 | .bind(name) |
| 64 | .bind(user_id) |
| 65 | .fetch_optional(db) |
| 66 | .await?; |
| 67 | |
| 68 | let Some((repo_id, visibility, owner_user_id, collab, org, is_admin, archived)) = row else { |
| 69 | return Ok(None); |
| 70 | }; |
| 71 | |
| 72 | // The same resolver the web uses — one implementation, so SSH and HTTP |
| 73 | // cannot disagree about who may do what (spec §6). |
| 74 | let access = permissions::resolve(AccessInputs { |
| 75 | visibility, |
| 76 | viewer: Some(Viewer { |
| 77 | user_id, |
| 78 | is_site_admin: is_admin, |
| 79 | }), |
| 80 | owner_user_id, |
| 81 | collaborator_role: collab, |
| 82 | org_role: org, |
| 83 | }); |
| 84 | |
| 85 | if !access.can_read() { |
| 86 | return Ok(None); |
| 87 | } |
| 88 | |
| 89 | Ok(Some(Resolved { |
| 90 | repo_id, |
| 91 | // An archived repository is read-only, on every transport. The settings |
| 92 | // page promises this, so it cannot be enforced only in the web UI. |
| 93 | can_push: access.can_push() && !archived, |
| 94 | archived, |
| 95 | })) |
| 96 | } |
| 97 | |
| 98 | /// Sharded path, identical to `GitStore::repo_path`. |
| 99 | pub fn repo_path(root: &str, id: Uuid) -> PathBuf { |
| 100 | let hex = id.simple().to_string(); |
| 101 | Path::new(root).join(&hex[..2]).join(format!("{hex}.git")) |
| 102 | } |
| 103 | |
| 104 | /// Spawn git, stream its output over the SSH channel, and return its stdin. |
| 105 | /// |
| 106 | /// The caller keeps the returned stdin and feeds it every `data` frame the |
| 107 | /// client sends, closing it on EOF. Without that the child blocks forever |
| 108 | /// waiting for the pack — git's stdio is a two-way conversation, not a |
| 109 | /// request/response. |
| 110 | #[allow(clippy::too_many_arguments)] |
| 111 | pub async fn run_git( |
| 112 | session: &mut Session, |
| 113 | channel: ChannelId, |
| 114 | service: Service, |
| 115 | dir: &Path, |
| 116 | db: &PgPool, |
| 117 | database_url: &str, |
| 118 | hook_binary: &str, |
| 119 | repo_id: Uuid, |
| 120 | user_id: Uuid, |
| 121 | ) -> Result<tokio::process::ChildStdin> { |
| 122 | let mut cmd = tokio::process::Command::new("git"); |
| 123 | cmd.arg(service.git_subcommand()) |
| 124 | .arg(".") |
| 125 | .current_dir(dir) |
| 126 | .stdin(Stdio::piped()) |
| 127 | .stdout(Stdio::piped()) |
| 128 | .stderr(Stdio::piped()) |
| 129 | // A clean environment: nothing from the connection leaks into git, and |
| 130 | // the repository's own config cannot redirect what we run. |
| 131 | .env_clear() |
| 132 | .env("PATH", "/usr/bin:/bin:/usr/local/bin") |
| 133 | .env("HOME", "/nonexistent") |
| 134 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 135 | .env("GIT_TERMINAL_PROMPT", "0") |
| 136 | // So a git that is still running when the runtime tears the task down |
| 137 | // is killed rather than orphaned. |
| 138 | .kill_on_drop(true) |
| 139 | // Consumed by the pre-receive hook. |
| 140 | .env("DOGFOOD_REPO_ID", repo_id.to_string()) |
| 141 | .env("DOGFOOD_PUSHER_ID", user_id.to_string()) |
| 142 | .env("DOGFOOD_HOOK_BINARY", hook_binary) |
| 143 | .env("DATABASE_URL", database_url) |
| 144 | // The check is mandatory: a hook that cannot verify protection must |
| 145 | // refuse the push rather than wave it through. |
| 146 | .env("DOGFOOD_ENFORCE", "1"); |
| 147 | |
| 148 | let mut child = cmd.spawn()?; |
| 149 | |
| 150 | let stdin = child.stdin.take().expect("piped"); |
| 151 | let mut stdout = child.stdout.take().expect("piped"); |
| 152 | let mut stderr = child.stderr.take().expect("piped"); |
| 153 | |
| 154 | let handle = session.handle(); |
| 155 | |
| 156 | let out_task = { |
| 157 | let handle = handle.clone(); |
| 158 | tokio::spawn(async move { |
| 159 | let mut buf = vec![0u8; 32 * 1024]; |
| 160 | // Once the client is gone we keep reading, discarding what we read. |
| 161 | // Stopping instead would leave git blocked writing into a pipe |
| 162 | // nobody drains — and since the reaper below waits on the child, it |
| 163 | // would wait forever. The pack is already being produced; draining |
| 164 | // it costs a copy and lets the process exit. |
| 165 | let mut client_gone = false; |
| 166 | loop { |
| 167 | match stdout.read(&mut buf).await { |
| 168 | Ok(0) | Err(_) => break, |
| 169 | Ok(n) => { |
| 170 | if client_gone { |
| 171 | continue; |
| 172 | } |
| 173 | if handle.data(channel, buf[..n].to_vec().into()).await.is_err() { |
| 174 | tracing::debug!("ssh client went away mid-stream; draining git"); |
| 175 | client_gone = true; |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | }) |
| 181 | }; |
| 182 | |
| 183 | let err_task = { |
| 184 | let handle = handle.clone(); |
| 185 | tokio::spawn(async move { |
| 186 | let mut buf = vec![0u8; 8 * 1024]; |
| 187 | // Drained past a dead channel for the same reason as stdout: a |
| 188 | // full stderr pipe blocks git just as thoroughly as a full stdout. |
| 189 | let mut client_gone = false; |
| 190 | loop { |
| 191 | match stderr.read(&mut buf).await { |
| 192 | Ok(0) | Err(_) => break, |
| 193 | Ok(n) => { |
| 194 | if client_gone { |
| 195 | continue; |
| 196 | } |
| 197 | // Stream 1 is stderr; this is where the pre-receive |
| 198 | // hook's rejection messages reach the user. |
| 199 | if handle |
| 200 | .extended_data(channel, 1, buf[..n].to_vec().into()) |
| 201 | .await |
| 202 | .is_err() |
| 203 | { |
| 204 | client_gone = true; |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | }) |
| 210 | }; |
| 211 | |
| 212 | // Reap the child and close the channel once it exits. This runs detached so |
| 213 | // `exec_request` can return and the handler can keep delivering client data |
| 214 | // to the stdin we hand back. |
| 215 | let db = db.clone(); |
| 216 | tokio::spawn(async move { |
| 217 | let status = match child.wait().await { |
| 218 | Ok(s) => s, |
| 219 | Err(e) => { |
| 220 | tracing::error!("waiting for git failed: {e}"); |
| 221 | let _ = handle.close(channel).await; |
| 222 | return; |
| 223 | } |
| 224 | }; |
| 225 | let _ = out_task.await; |
| 226 | let _ = err_task.await; |
| 227 | |
| 228 | // A push that git accepted has to be indexed, exactly as the HTTP |
| 229 | // transport does it — without this the objects land but the site never |
| 230 | // learns about them, so the push is invisible until somebody runs |
| 231 | // `dogfood-admin reindex` by hand. |
| 232 | // |
| 233 | // Only on success: a rejected push (a protected bookmark, say) wrote |
| 234 | // nothing to index. Failures here are logged and dropped rather than |
| 235 | // surfaced, because the objects are already durable and failing the |
| 236 | // client would make it retry a push that succeeded. |
| 237 | if service.is_write() && status.success() { |
| 238 | if let Err(e) = enqueue_index(&db, repo_id, Some(user_id)).await { |
| 239 | tracing::error!(repo = %repo_id, "enqueuing IndexPush failed: {e:#}"); |
| 240 | } |
| 241 | if let Err(e) = sqlx::query("UPDATE repos SET pushed_at = now() WHERE id = $1") |
| 242 | .bind(repo_id) |
| 243 | .execute(&db) |
| 244 | .await |
| 245 | { |
| 246 | tracing::warn!(repo = %repo_id, "recording push time failed: {e}"); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | let code = status.code().unwrap_or(1) as u32; |
| 251 | let _ = handle.exit_status_request(channel, code).await; |
| 252 | let _ = handle.eof(channel).await; |
| 253 | let _ = handle.close(channel).await; |
| 254 | }); |
| 255 | |
| 256 | Ok(stdin) |
| 257 | } |
| 258 | |
| 259 | /// Queue an indexing job for a repository. |
| 260 | /// |
| 261 | /// Deliberately the same payload shape the HTTP transport enqueues — one job |
| 262 | /// kind, one worker, whichever way the push arrived. |
| 263 | async fn enqueue_index(db: &PgPool, repo_id: Uuid, pushed_by: Option<Uuid>) -> Result<()> { |
| 264 | sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)") |
| 265 | .bind(df_db::ids::new_id()) |
| 266 | .bind(serde_json::json!({ |
| 267 | "repo_id": repo_id, |
| 268 | "pushed_by": pushed_by, |
| 269 | })) |
| 270 | .execute(db) |
| 271 | .await?; |
| 272 | Ok(()) |
| 273 | } |
| 274 | |
| 275 | #[cfg(test)] |
| 276 | mod tests { |
| 277 | use super::*; |
| 278 | |
| 279 | #[test] |
| 280 | fn repo_paths_match_the_store_layout() { |
| 281 | // These two must never diverge, or SSH serves a different directory |
| 282 | // than the web does. |
| 283 | let id = Uuid::parse_str("0191f0aa-1234-7abc-8def-0123456789ab").unwrap(); |
| 284 | assert_eq!( |
| 285 | repo_path("/srv/repos", id), |
| 286 | PathBuf::from("/srv/repos/01/0191f0aa12347abc8def0123456789ab.git") |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn repo_paths_never_escape_the_root() { |
| 292 | for _ in 0..50 { |
| 293 | let p = repo_path("/srv/repos", Uuid::now_v7()); |
| 294 | assert!(p.starts_with("/srv/repos")); |
| 295 | assert!(!p.to_string_lossy().contains("..")); |
| 296 | } |
| 297 | } |
| 298 | } |
298 lines · Rust