| 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