steid

@jamesgill /

1//! Serving the git smart-HTTP protocol.
2//!
3//! The protocol itself belongs to `git http-backend`. What belongs here is the part git
4//! cannot decide: whether this actor may do this to this repository, settled **before**
5//! the backend is spawned. Once pack data is moving, refusing is no longer an option.
6
7use crate::domain::{
8 Actor, DomainError, OrgName, RepoName,
9 repository::{MembershipRepository, OrgRepository, RepoRepository},
10};
11
12use super::{
13 authz::is_org_member,
14 error::Result,
15 port::{ByteStream, GitMethod, GitProtocolServer, GitRequest, GitResponse},
16};
17
18/// A git service, as named in the protocol.
19///
20/// The wire spelling and the enum are kept together because the two must not drift: the
21/// same value decides authorization and is handed to the backend.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GitService {
24 /// Sends objects to a client. What `git clone` and `git fetch` use.
25 UploadPack,
26 /// Receives objects from a client. What `git push` uses.
27 ReceivePack,
28}
29
30impl GitService {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::UploadPack => "git-upload-pack",
34 Self::ReceivePack => "git-receive-pack",
35 }
36 }
37
38 /// What this service asks permission to do.
39 ///
40 /// The whole authorization surface of the git transport is this function. Adding a
41 /// service without deciding its operation is a compile error, which is the point of
42 /// matching exhaustively rather than defaulting.
43 pub fn operation(self) -> GitOperation {
44 match self {
45 Self::UploadPack => GitOperation::Read,
46 Self::ReceivePack => GitOperation::Write,
47 }
48 }
49}
50
51/// Parsing returns `Result`, never `Option`: an unrecognised service that silently
52/// defaulted to read would eventually default the wrong way.
53impl std::str::FromStr for GitService {
54 type Err = DomainError;
55
56 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
57 match value {
58 "git-upload-pack" => Ok(Self::UploadPack),
59 "git-receive-pack" => Ok(Self::ReceivePack),
60 other => Err(DomainError::validation(
61 "service",
62 format!("unknown git service {other:?}"),
63 )),
64 }
65 }
66}
67
68/// What a git request wants to do to a repository.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum GitOperation {
71 Read,
72 Write,
73}
74
75/// Which endpoint of the smart protocol is being asked for.
76///
77/// Named by the caller from the route it matched, never parsed out of a URL. Three
78/// routes, three literal values — so a path cannot be coaxed into meaning a different
79/// operation than the one that was authorized.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum GitEndpoint {
82 /// `GET {repo}.git/info/refs?service=…` — the ref advertisement that opens an
83 /// exchange. Carries the service of the operation it is opening, so a push
84 /// advertisement is a write before a single object moves.
85 Advertisement(GitService),
86 /// `POST {repo}.git/{service}` — the negotiation and the pack itself.
87 Rpc(GitService),
88}
89
90impl GitEndpoint {
91 fn service(self) -> GitService {
92 match self {
93 Self::Advertisement(service) | Self::Rpc(service) => service,
94 }
95 }
96
97 fn method(self) -> GitMethod {
98 match self {
99 Self::Advertisement(_) => GitMethod::Get,
100 Self::Rpc(_) => GitMethod::Post,
101 }
102 }
103}
104
105/// The request headers the backend needs to see.
106///
107/// Not every header — only the four that change what git does. Forwarding the rest
108/// would be handing a subprocess arbitrary client input for no benefit.
109#[derive(Debug, Default, Clone, PartialEq, Eq)]
110pub struct GitClientHeaders {
111 pub content_type: Option<String>,
112 /// Real clients gzip a request body once a repository has more than a handful of
113 /// refs. Dropping this makes clones of real repositories fail while a one-ref test
114 /// repository keeps passing.
115 pub content_encoding: Option<String>,
116 pub content_length: Option<String>,
117 /// `version=2` for any modern client. Dropping it downgrades to v0 silently.
118 pub git_protocol: Option<String>,
119}
120
121/// Serves one git protocol request against a repository the actor is allowed to reach.
122///
123/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", exactly
124/// as [`view_repo`](super::repo::view_repo) does, and the caller must render them
125/// identically — a private repository has to be absent, not merely unclonable.
126///
127/// Writes are refused outright: push arrives with personal access tokens in Milestone
128/// 4b. The refusal is a real authorization decision made here, not a reliance on `git
129/// http-backend` disabling `receive-pack` by default — a default that helpfully changes
130/// is not a permission check.
131#[allow(clippy::too_many_arguments)]
132pub async fn serve_git(
133 handle: &OrgName,
134 name: &RepoName,
135 endpoint: GitEndpoint,
136 headers: GitClientHeaders,
137 body: ByteStream,
138 actor: &Actor,
139 orgs: &impl OrgRepository,
140 memberships: &impl MembershipRepository,
141 repos: &impl RepoRepository,
142 protocol: &impl GitProtocolServer,
143) -> Result<Option<GitResponse>> {
144 let Some(org) = orgs.find_by_name(handle).await? else {
145 return Ok(None);
146 };
147
148 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
149 return Ok(None);
150 };
151
152 // Existence is settled before permission, so a push to a repository the actor may
153 // not even see answers "no such repository" rather than "not allowed" — the latter
154 // would confirm it exists.
155 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
156 return Ok(None);
157 }
158
159 if endpoint.service().operation() == GitOperation::Write {
160 return Err(DomainError::Forbidden.into());
161 }
162
163 let service = endpoint.service();
164 let path_info = format!("/{}/{}.git", org.name, repo.name);
165
166 // Built here from validated values rather than forwarded from the URL. `OrgName` and
167 // `RepoName` already made traversal impossible, so the backend cannot be pointed at
168 // a path this function did not agree to.
169 let (path_info, query) = match endpoint {
170 GitEndpoint::Advertisement(_) => (
171 format!("{path_info}/info/refs"),
172 format!("service={}", service.as_str()),
173 ),
174 GitEndpoint::Rpc(_) => (format!("{path_info}/{}", service.as_str()), String::new()),
175 };
176
177 let response = protocol
178 .serve(GitRequest {
179 method: endpoint.method(),
180 path_info,
181 query,
182 content_type: headers.content_type,
183 content_encoding: headers.content_encoding,
184 content_length: headers.content_length,
185 git_protocol: headers.git_protocol,
186 body,
187 })
188 .await?;
189
190 Ok(Some(response))
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::{
197 domain::{
198 Membership, MembershipId, OrgId, Organization, RepoId, Repository, Role, UserId,
199 Visibility,
200 },
201 infrastructure::{
202 git::InMemoryGitProtocol,
203 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
204 },
205 };
206
207 struct Fixture {
208 orgs: InMemoryOrgRepo,
209 memberships: InMemoryMembershipRepo,
210 repos: InMemoryRepoRepo,
211 protocol: InMemoryGitProtocol,
212 owner: Actor,
213 member: Actor,
214 stranger: Actor,
215 handle: OrgName,
216 }
217
218 async fn fixture() -> Fixture {
219 let orgs = InMemoryOrgRepo::new();
220 let memberships = InMemoryMembershipRepo::new();
221
222 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
223 orgs.save(&org).await.expect("save org");
224
225 let owner = UserId::generate();
226 let member = UserId::generate();
227
228 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
229 memberships
230 .save(&Membership::new(
231 MembershipId::generate(),
232 org.id.clone(),
233 user.clone(),
234 role,
235 ))
236 .await
237 .expect("save membership");
238 }
239
240 let repos = InMemoryRepoRepo::new();
241 for (name, visibility) in [
242 ("steid", Visibility::Public),
243 ("secret", Visibility::Private),
244 ] {
245 repos
246 .save(
247 &Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
248 .expect("valid repo"),
249 )
250 .await
251 .expect("save repo");
252 }
253
254 Fixture {
255 orgs,
256 memberships,
257 repos,
258 protocol: InMemoryGitProtocol::new(),
259 owner: Actor::User(owner),
260 member: Actor::User(member),
261 stranger: Actor::User(UserId::generate()),
262 handle: org.name,
263 }
264 }
265
266 impl Fixture {
267 async fn serve(
268 &self,
269 actor: &Actor,
270 name: &str,
271 endpoint: GitEndpoint,
272 ) -> Result<Option<GitResponse>> {
273 self.serve_with(actor, name, endpoint, GitClientHeaders::default())
274 .await
275 }
276
277 async fn serve_with(
278 &self,
279 actor: &Actor,
280 name: &str,
281 endpoint: GitEndpoint,
282 headers: GitClientHeaders,
283 ) -> Result<Option<GitResponse>> {
284 serve_git(
285 &self.handle,
286 &RepoName::new(name).expect("valid name"),
287 endpoint,
288 headers,
289 Box::pin(tokio::io::empty()),
290 actor,
291 &self.orgs,
292 &self.memberships,
293 &self.repos,
294 &self.protocol,
295 )
296 .await
297 }
298 }
299
300 fn advertise_clone() -> GitEndpoint {
301 GitEndpoint::Advertisement(GitService::UploadPack)
302 }
303
304 fn advertise_push() -> GitEndpoint {
305 GitEndpoint::Advertisement(GitService::ReceivePack)
306 }
307
308 // --- what reaches the backend ----------------------------------------------
309
310 #[tokio::test]
311 async fn an_advertisement_is_addressed_to_the_repository_on_disk() {
312 // The URL says `/acme/repos/steid.git`; the disk says `acme/steid.git`. The use
313 // case builds the second from validated values rather than trusting the first.
314 let f = fixture().await;
315
316 f.serve(&Actor::Anonymous, "steid", advertise_clone())
317 .await
318 .expect("should serve")
319 .expect("should be visible");
320
321 let request = f.protocol.requests().pop().expect("backend was reached");
322 assert_eq!(request.path_info, "/acme/steid.git/info/refs");
323 assert_eq!(request.query, "service=git-upload-pack");
324 assert_eq!(request.method, GitMethod::Get);
325 }
326
327 #[tokio::test]
328 async fn an_rpc_posts_to_the_service_with_no_query() {
329 let f = fixture().await;
330
331 f.serve(
332 &Actor::Anonymous,
333 "steid",
334 GitEndpoint::Rpc(GitService::UploadPack),
335 )
336 .await
337 .expect("should serve")
338 .expect("should be visible");
339
340 let request = f.protocol.requests().pop().expect("backend was reached");
341 assert_eq!(request.path_info, "/acme/steid.git/git-upload-pack");
342 assert_eq!(request.query, "");
343 assert_eq!(request.method, GitMethod::Post);
344 }
345
346 #[tokio::test]
347 async fn the_headers_that_change_gits_behaviour_are_forwarded() {
348 // Both of these fail silently when dropped: one downgrades the protocol, the
349 // other hands git a compressed body it will not recognise.
350 let f = fixture().await;
351
352 f.serve_with(
353 &Actor::Anonymous,
354 "steid",
355 GitEndpoint::Rpc(GitService::UploadPack),
356 GitClientHeaders {
357 content_encoding: Some("gzip".to_owned()),
358 git_protocol: Some("version=2".to_owned()),
359 ..GitClientHeaders::default()
360 },
361 )
362 .await
363 .expect("should serve")
364 .expect("should be visible");
365
366 let request = f.protocol.requests().pop().expect("backend was reached");
367 assert_eq!(request.content_encoding.as_deref(), Some("gzip"));
368 assert_eq!(request.git_protocol.as_deref(), Some("version=2"));
369 }
370
371 // --- visibility --------------------------------------------------------------
372
373 #[tokio::test]
374 async fn a_public_repository_is_clonable_by_anyone() {
375 let f = fixture().await;
376
377 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
378 assert!(
379 f.serve(actor, "steid", advertise_clone())
380 .await
381 .expect("should serve")
382 .is_some(),
383 "{actor:?} should be able to clone a public repository"
384 );
385 }
386 }
387
388 #[tokio::test]
389 async fn a_private_repository_is_absent_for_outsiders_and_never_reaches_git() {
390 // `None`, not an error, and — the part worth testing — no bytes flow. A refusal
391 // that arrives after the backend is spawned is not a refusal.
392 let f = fixture().await;
393
394 for actor in [&Actor::Anonymous, &f.stranger] {
395 assert!(
396 f.serve(actor, "secret", advertise_clone())
397 .await
398 .expect("should serve")
399 .is_none(),
400 "{actor:?} should not see a private repository"
401 );
402 }
403
404 assert!(!f.protocol.was_called());
405 }
406
407 #[tokio::test]
408 async fn a_private_repository_is_clonable_by_any_member() {
409 let f = fixture().await;
410
411 for actor in [&f.member, &f.owner] {
412 assert!(
413 f.serve(actor, "secret", advertise_clone())
414 .await
415 .expect("should serve")
416 .is_some(),
417 "{actor:?} should be able to clone a private repository"
418 );
419 }
420 }
421
422 #[tokio::test]
423 async fn an_unknown_repository_is_absent() {
424 let f = fixture().await;
425
426 assert!(
427 f.serve(&f.owner, "nothing-here", advertise_clone())
428 .await
429 .expect("should serve")
430 .is_none()
431 );
432 assert!(!f.protocol.was_called());
433 }
434
435 #[tokio::test]
436 async fn an_unknown_handle_is_absent() {
437 let f = fixture().await;
438 let missing = OrgName::new("nobody").expect("valid handle");
439
440 let served = serve_git(
441 &missing,
442 &RepoName::new("steid").expect("valid"),
443 advertise_clone(),
444 GitClientHeaders::default(),
445 Box::pin(tokio::io::empty()),
446 &f.owner,
447 &f.orgs,
448 &f.memberships,
449 &f.repos,
450 &f.protocol,
451 )
452 .await
453 .expect("should serve");
454
455 assert!(served.is_none());
456 assert!(!f.protocol.was_called());
457 }
458
459 // --- writes ------------------------------------------------------------------
460
461 #[tokio::test]
462 async fn pushing_is_refused_for_everyone_including_the_owner() {
463 // Milestone 4a has no way to authenticate a push, so nobody may make one. This
464 // is an explicit refusal rather than a reliance on git's own default, which is
465 // configuration and could change under us.
466 let f = fixture().await;
467
468 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
469 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
470 let error = f
471 .serve(actor, "steid", endpoint)
472 .await
473 .expect_err("push should be refused");
474
475 assert!(
476 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
477 "{actor:?} on {endpoint:?} should be forbidden, got {error:?}"
478 );
479 }
480 }
481
482 assert!(!f.protocol.was_called());
483 }
484
485 #[tokio::test]
486 async fn a_push_to_an_invisible_repository_is_absent_rather_than_forbidden() {
487 // Order matters: existence is settled before permission. Answering "forbidden"
488 // here would confirm that a private repository by that name exists.
489 let f = fixture().await;
490
491 let served = f
492 .serve(&Actor::Anonymous, "secret", advertise_push())
493 .await
494 .expect("should serve");
495
496 assert!(served.is_none());
497 }
498
499 // --- service parsing ---------------------------------------------------------
500
501 #[tokio::test]
502 async fn a_service_round_trips_through_its_wire_name() {
503 for service in [GitService::UploadPack, GitService::ReceivePack] {
504 assert_eq!(
505 service
506 .as_str()
507 .parse::<GitService>()
508 .expect("known service"),
509 service
510 );
511 }
512 }
513
514 #[tokio::test]
515 async fn an_unknown_service_is_an_error_not_a_default() {
516 assert!("git-do-whatever".parse::<GitService>().is_err());
517 assert!("".parse::<GitService>().is_err());
518 }
519
520 #[tokio::test]
521 async fn only_upload_pack_reads() {
522 assert_eq!(GitService::UploadPack.operation(), GitOperation::Read);
523 assert_eq!(GitService::ReceivePack.operation(), GitOperation::Write);
524 }
525}