Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Repository resolution, authorization, and git dispatch for SSH.
2
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5
6use anyhow::Result;
7use df_auth::permissions::{self, AccessInputs, Viewer};
8use df_db::models::{OrgRole, RepoRole, Visibility};
9use russh::server::Session;
10use russh::ChannelId;
11use sqlx::PgPool;
12use tokio::io::AsyncReadExt;
13use uuid::Uuid;
14
15use crate::exec::Service;
16
17pub 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).
29pub 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`.
99pub 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)]
111pub 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.
263async 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)]
276mod 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