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, is_org_owner},
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/// Reading a public repository is open to anyone. Reading a private one needs any
128/// membership of the owning organisation — seeing is weaker than changing. **Writing
129/// needs `Role::Owner`**, matching every other mutation in Steid: a member's read access
130/// is not permission to rewrite someone's history.
131///
132/// The refusal is made here rather than left to `git http-backend` disabling
133/// `receive-pack` by default. That default is configuration, and a default that
134/// helpfully changes is not a permission check.
135#[allow(clippy::too_many_arguments)]
136pub async fn serve_git(
137 handle: &OrgName,
138 name: &RepoName,
139 endpoint: GitEndpoint,
140 headers: GitClientHeaders,
141 body: ByteStream,
142 actor: &Actor,
143 orgs: &impl OrgRepository,
144 memberships: &impl MembershipRepository,
145 repos: &impl RepoRepository,
146 protocol: &impl GitProtocolServer,
147) -> Result<Option<GitResponse>> {
148 let Some(org) = orgs.find_by_name(handle).await? else {
149 return Ok(None);
150 };
151
152 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
153 return Ok(None);
154 };
155
156 // Existence is settled before permission, so a push to a repository the actor may
157 // not even see answers "no such repository" rather than "not allowed" — the latter
158 // would confirm it exists.
159 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
160 return Ok(None);
161 }
162
163 let writing = endpoint.service().operation() == GitOperation::Write;
164
165 if writing && !is_org_owner(&org, actor, memberships).await? {
166 return Err(DomainError::Forbidden.into());
167 }
168
169 let service = endpoint.service();
170 let path_info = format!("/{}/{}.git", org.name, repo.name);
171
172 // Built here from validated values rather than forwarded from the URL. `OrgName` and
173 // `RepoName` already made traversal impossible, so the backend cannot be pointed at
174 // a path this function did not agree to.
175 let (path_info, query) = match endpoint {
176 GitEndpoint::Advertisement(_) => (
177 format!("{path_info}/info/refs"),
178 format!("service={}", service.as_str()),
179 ),
180 GitEndpoint::Rpc(_) => (format!("{path_info}/{}", service.as_str()), String::new()),
181 };
182
183 let response = protocol
184 .serve(GitRequest {
185 method: endpoint.method(),
186 path_info,
187 query,
188 content_type: headers.content_type,
189 content_encoding: headers.content_encoding,
190 content_length: headers.content_length,
191 git_protocol: headers.git_protocol,
192 // Past the gate above, so this is only ever true for a write that was
193 // authorized. Left false for a read, which never needs it.
194 allow_receive_pack: writing,
195 body,
196 })
197 .await?;
198
199 Ok(Some(response))
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::{
206 domain::{
207 Membership, MembershipId, OrgId, Organization, RepoId, Repository, Role, UserId,
208 Visibility,
209 },
210 infrastructure::{
211 git::InMemoryGitProtocol,
212 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
213 },
214 };
215
216 struct Fixture {
217 orgs: InMemoryOrgRepo,
218 memberships: InMemoryMembershipRepo,
219 repos: InMemoryRepoRepo,
220 protocol: InMemoryGitProtocol,
221 owner: Actor,
222 member: Actor,
223 stranger: Actor,
224 handle: OrgName,
225 }
226
227 async fn fixture() -> Fixture {
228 let orgs = InMemoryOrgRepo::new();
229 let memberships = InMemoryMembershipRepo::new();
230
231 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
232 orgs.save(&org).await.expect("save org");
233
234 let owner = UserId::generate();
235 let member = UserId::generate();
236
237 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
238 memberships
239 .save(&Membership::new(
240 MembershipId::generate(),
241 org.id.clone(),
242 user.clone(),
243 role,
244 ))
245 .await
246 .expect("save membership");
247 }
248
249 let repos = InMemoryRepoRepo::new();
250 for (name, visibility) in [
251 ("steid", Visibility::Public),
252 ("secret", Visibility::Private),
253 ] {
254 repos
255 .save(
256 &Repository::new(RepoId::generate(), org.id.clone(), name, None, visibility)
257 .expect("valid repo"),
258 )
259 .await
260 .expect("save repo");
261 }
262
263 Fixture {
264 orgs,
265 memberships,
266 repos,
267 protocol: InMemoryGitProtocol::new(),
268 owner: Actor::User(owner),
269 member: Actor::User(member),
270 stranger: Actor::User(UserId::generate()),
271 handle: org.name,
272 }
273 }
274
275 impl Fixture {
276 async fn serve(
277 &self,
278 actor: &Actor,
279 name: &str,
280 endpoint: GitEndpoint,
281 ) -> Result<Option<GitResponse>> {
282 self.serve_with(actor, name, endpoint, GitClientHeaders::default())
283 .await
284 }
285
286 async fn serve_with(
287 &self,
288 actor: &Actor,
289 name: &str,
290 endpoint: GitEndpoint,
291 headers: GitClientHeaders,
292 ) -> Result<Option<GitResponse>> {
293 serve_git(
294 &self.handle,
295 &RepoName::new(name).expect("valid name"),
296 endpoint,
297 headers,
298 Box::pin(tokio::io::empty()),
299 actor,
300 &self.orgs,
301 &self.memberships,
302 &self.repos,
303 &self.protocol,
304 )
305 .await
306 }
307 }
308
309 fn advertise_clone() -> GitEndpoint {
310 GitEndpoint::Advertisement(GitService::UploadPack)
311 }
312
313 fn advertise_push() -> GitEndpoint {
314 GitEndpoint::Advertisement(GitService::ReceivePack)
315 }
316
317 // --- what reaches the backend ----------------------------------------------
318
319 #[tokio::test]
320 async fn an_advertisement_is_addressed_to_the_repository_on_disk() {
321 // The URL says `/acme/repos/steid.git`; the disk says `acme/steid.git`. The use
322 // case builds the second from validated values rather than trusting the first.
323 let f = fixture().await;
324
325 f.serve(&Actor::Anonymous, "steid", advertise_clone())
326 .await
327 .expect("should serve")
328 .expect("should be visible");
329
330 let request = f.protocol.requests().pop().expect("backend was reached");
331 assert_eq!(request.path_info, "/acme/steid.git/info/refs");
332 assert_eq!(request.query, "service=git-upload-pack");
333 assert_eq!(request.method, GitMethod::Get);
334 }
335
336 #[tokio::test]
337 async fn an_rpc_posts_to_the_service_with_no_query() {
338 let f = fixture().await;
339
340 f.serve(
341 &Actor::Anonymous,
342 "steid",
343 GitEndpoint::Rpc(GitService::UploadPack),
344 )
345 .await
346 .expect("should serve")
347 .expect("should be visible");
348
349 let request = f.protocol.requests().pop().expect("backend was reached");
350 assert_eq!(request.path_info, "/acme/steid.git/git-upload-pack");
351 assert_eq!(request.query, "");
352 assert_eq!(request.method, GitMethod::Post);
353 }
354
355 #[tokio::test]
356 async fn the_headers_that_change_gits_behaviour_are_forwarded() {
357 // Both of these fail silently when dropped: one downgrades the protocol, the
358 // other hands git a compressed body it will not recognise.
359 let f = fixture().await;
360
361 f.serve_with(
362 &Actor::Anonymous,
363 "steid",
364 GitEndpoint::Rpc(GitService::UploadPack),
365 GitClientHeaders {
366 content_encoding: Some("gzip".to_owned()),
367 git_protocol: Some("version=2".to_owned()),
368 ..GitClientHeaders::default()
369 },
370 )
371 .await
372 .expect("should serve")
373 .expect("should be visible");
374
375 let request = f.protocol.requests().pop().expect("backend was reached");
376 assert_eq!(request.content_encoding.as_deref(), Some("gzip"));
377 assert_eq!(request.git_protocol.as_deref(), Some("version=2"));
378 }
379
380 // --- visibility --------------------------------------------------------------
381
382 #[tokio::test]
383 async fn a_public_repository_is_clonable_by_anyone() {
384 let f = fixture().await;
385
386 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
387 assert!(
388 f.serve(actor, "steid", advertise_clone())
389 .await
390 .expect("should serve")
391 .is_some(),
392 "{actor:?} should be able to clone a public repository"
393 );
394 }
395 }
396
397 #[tokio::test]
398 async fn a_private_repository_is_absent_for_outsiders_and_never_reaches_git() {
399 // `None`, not an error, and — the part worth testing — no bytes flow. A refusal
400 // that arrives after the backend is spawned is not a refusal.
401 let f = fixture().await;
402
403 for actor in [&Actor::Anonymous, &f.stranger] {
404 assert!(
405 f.serve(actor, "secret", advertise_clone())
406 .await
407 .expect("should serve")
408 .is_none(),
409 "{actor:?} should not see a private repository"
410 );
411 }
412
413 assert!(!f.protocol.was_called());
414 }
415
416 #[tokio::test]
417 async fn a_private_repository_is_clonable_by_any_member() {
418 let f = fixture().await;
419
420 for actor in [&f.member, &f.owner] {
421 assert!(
422 f.serve(actor, "secret", advertise_clone())
423 .await
424 .expect("should serve")
425 .is_some(),
426 "{actor:?} should be able to clone a private repository"
427 );
428 }
429 }
430
431 #[tokio::test]
432 async fn an_unknown_repository_is_absent() {
433 let f = fixture().await;
434
435 assert!(
436 f.serve(&f.owner, "nothing-here", advertise_clone())
437 .await
438 .expect("should serve")
439 .is_none()
440 );
441 assert!(!f.protocol.was_called());
442 }
443
444 #[tokio::test]
445 async fn an_unknown_handle_is_absent() {
446 let f = fixture().await;
447 let missing = OrgName::new("nobody").expect("valid handle");
448
449 let served = serve_git(
450 &missing,
451 &RepoName::new("steid").expect("valid"),
452 advertise_clone(),
453 GitClientHeaders::default(),
454 Box::pin(tokio::io::empty()),
455 &f.owner,
456 &f.orgs,
457 &f.memberships,
458 &f.repos,
459 &f.protocol,
460 )
461 .await
462 .expect("should serve");
463
464 assert!(served.is_none());
465 assert!(!f.protocol.was_called());
466 }
467
468 // --- writes ------------------------------------------------------------------
469
470 #[tokio::test]
471 async fn receive_pack_is_enabled_only_for_an_authorized_write() {
472 // The flag git keys off. A read must never turn it on, and a refused write must
473 // never reach the backend at all — so the only true is a push that passed.
474 let f = fixture().await;
475
476 f.serve(&Actor::Anonymous, "steid", advertise_clone())
477 .await
478 .expect("should serve")
479 .expect("visible");
480 assert!(!f.protocol.requests()[0].allow_receive_pack);
481
482 f.serve(&f.owner, "steid", advertise_push())
483 .await
484 .expect("should serve")
485 .expect("allowed");
486 assert!(f.protocol.requests()[1].allow_receive_pack);
487 }
488
489 #[tokio::test]
490 async fn the_owner_may_push() {
491 let f = fixture().await;
492
493 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
494 f.serve(&f.owner, "steid", endpoint)
495 .await
496 .expect("should serve")
497 .expect("should be allowed");
498 }
499
500 assert_eq!(f.protocol.requests().len(), 2);
501 }
502
503 #[tokio::test]
504 async fn the_owner_may_push_to_a_private_repository() {
505 let f = fixture().await;
506
507 f.serve(&f.owner, "secret", advertise_push())
508 .await
509 .expect("should serve")
510 .expect("should be allowed");
511 }
512
513 #[tokio::test]
514 async fn a_member_who_is_not_the_owner_may_not_push() {
515 // Read access is not permission to rewrite history. The same rule as creating a
516 // repository, and the same rule attempt #2 shipped over SSH.
517 let f = fixture().await;
518
519 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
520 let error = f
521 .serve(&f.member, "steid", endpoint)
522 .await
523 .expect_err("push should be refused");
524
525 assert!(
526 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
527 "a member on {endpoint:?} should be forbidden, got {error:?}"
528 );
529 }
530
531 assert!(!f.protocol.was_called());
532 }
533
534 #[tokio::test]
535 async fn strangers_and_anonymous_callers_may_not_push() {
536 let f = fixture().await;
537
538 for actor in [&Actor::Anonymous, &f.stranger] {
539 let error = f
540 .serve(actor, "steid", advertise_push())
541 .await
542 .expect_err("push should be refused");
543
544 assert!(
545 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
546 "{actor:?} should be forbidden"
547 );
548 }
549
550 assert!(!f.protocol.was_called());
551 }
552
553 #[tokio::test]
554 async fn a_refused_push_never_reaches_git() {
555 // The ordering that matters: once pack data is moving, refusing is not an option.
556 let f = fixture().await;
557
558 let _ = f.serve(&f.member, "steid", advertise_push()).await;
559
560 assert!(!f.protocol.was_called());
561 }
562
563 #[tokio::test]
564 async fn a_push_to_an_invisible_repository_is_absent_rather_than_forbidden() {
565 // Order matters: existence is settled before permission. Answering "forbidden"
566 // here would confirm that a private repository by that name exists.
567 let f = fixture().await;
568
569 let served = f
570 .serve(&Actor::Anonymous, "secret", advertise_push())
571 .await
572 .expect("should serve");
573
574 assert!(served.is_none());
575 }
576
577 // --- service parsing ---------------------------------------------------------
578
579 #[tokio::test]
580 async fn a_service_round_trips_through_its_wire_name() {
581 for service in [GitService::UploadPack, GitService::ReceivePack] {
582 assert_eq!(
583 service
584 .as_str()
585 .parse::<GitService>()
586 .expect("known service"),
587 service
588 );
589 }
590 }
591
592 #[tokio::test]
593 async fn an_unknown_service_is_an_error_not_a_default() {
594 assert!("git-do-whatever".parse::<GitService>().is_err());
595 assert!("".parse::<GitService>().is_err());
596 }
597
598 #[tokio::test]
599 async fn only_upload_pack_reads() {
600 assert_eq!(GitService::UploadPack.operation(), GitOperation::Read);
601 assert_eq!(GitService::ReceivePack.operation(), GitOperation::Write);
602 }
603}