@jpgilldev / steid

24.3 KBRaw
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 std::time::SystemTime;
8
9use crate::domain::{
10 Actor, DomainError, OrgName, RepoName,
11 repository::{MembershipRepository, OrgRepository, RepoRepository},
12};
13
14use super::{
15 authz::{is_org_member, is_org_owner},
16 error::Result,
17 port::{ByteStream, GitMethod, GitProtocolServer, GitRequest, GitResponse},
18};
19
20/// A git service, as named in the protocol.
21///
22/// The wire spelling and the enum are kept together because the two must not drift: the
23/// same value decides authorization and is handed to the backend.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum GitService {
26 /// Sends objects to a client. What `git clone` and `git fetch` use.
27 UploadPack,
28 /// Receives objects from a client. What `git push` uses.
29 ReceivePack,
30}
31
32impl GitService {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::UploadPack => "git-upload-pack",
36 Self::ReceivePack => "git-receive-pack",
37 }
38 }
39
40 /// What this service asks permission to do.
41 ///
42 /// The whole authorization surface of the git transport is this function. Adding a
43 /// service without deciding its operation is a compile error, which is the point of
44 /// matching exhaustively rather than defaulting.
45 pub fn operation(self) -> GitOperation {
46 match self {
47 Self::UploadPack => GitOperation::Read,
48 Self::ReceivePack => GitOperation::Write,
49 }
50 }
51}
52
53/// Parsing returns `Result`, never `Option`: an unrecognised service that silently
54/// defaulted to read would eventually default the wrong way.
55impl std::str::FromStr for GitService {
56 type Err = DomainError;
57
58 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
59 match value {
60 "git-upload-pack" => Ok(Self::UploadPack),
61 "git-receive-pack" => Ok(Self::ReceivePack),
62 other => Err(DomainError::validation(
63 "service",
64 format!("unknown git service {other:?}"),
65 )),
66 }
67 }
68}
69
70/// What a git request wants to do to a repository.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum GitOperation {
73 Read,
74 Write,
75}
76
77/// Which endpoint of the smart protocol is being asked for.
78///
79/// Named by the caller from the route it matched, never parsed out of a URL. Three
80/// routes, three literal values — so a path cannot be coaxed into meaning a different
81/// operation than the one that was authorized.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum GitEndpoint {
84 /// `GET {repo}.git/info/refs?service=…` — the ref advertisement that opens an
85 /// exchange. Carries the service of the operation it is opening, so a push
86 /// advertisement is a write before a single object moves.
87 Advertisement(GitService),
88 /// `POST {repo}.git/{service}` — the negotiation and the pack itself.
89 Rpc(GitService),
90}
91
92impl GitEndpoint {
93 fn service(self) -> GitService {
94 match self {
95 Self::Advertisement(service) | Self::Rpc(service) => service,
96 }
97 }
98
99 fn method(self) -> GitMethod {
100 match self {
101 Self::Advertisement(_) => GitMethod::Get,
102 Self::Rpc(_) => GitMethod::Post,
103 }
104 }
105}
106
107/// The request headers the backend needs to see.
108///
109/// Not every header — only the four that change what git does. Forwarding the rest
110/// would be handing a subprocess arbitrary client input for no benefit.
111#[derive(Debug, Default, Clone, PartialEq, Eq)]
112pub struct GitClientHeaders {
113 pub content_type: Option<String>,
114 /// Real clients gzip a request body once a repository has more than a handful of
115 /// refs. Dropping this makes clones of real repositories fail while a one-ref test
116 /// repository keeps passing.
117 pub content_encoding: Option<String>,
118 pub content_length: Option<String>,
119 /// `version=2` for any modern client. Dropping it downgrades to v0 silently.
120 pub git_protocol: Option<String>,
121}
122
123/// Serves one git protocol request against a repository the actor is allowed to reach.
124///
125/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", exactly
126/// as [`view_repo`](super::repo::view_repo) does, and the caller must render them
127/// identically — a private repository has to be absent, not merely unclonable.
128///
129/// Reading a public repository is open to anyone. Reading a private one needs any
130/// membership of the owning organisation — seeing is weaker than changing. **Writing
131/// needs `Role::Owner`**, matching every other mutation in Steid: a member's read access
132/// is not permission to rewrite someone's history.
133///
134/// The refusal is made here rather than left to `git http-backend` disabling
135/// `receive-pack` by default. That default is configuration, and a default that
136/// helpfully changes is not a permission check.
137#[allow(clippy::too_many_arguments)]
138pub async fn serve_git(
139 handle: &OrgName,
140 name: &RepoName,
141 endpoint: GitEndpoint,
142 headers: GitClientHeaders,
143 body: ByteStream,
144 actor: &Actor,
145 orgs: &impl OrgRepository,
146 memberships: &impl MembershipRepository,
147 repos: &impl RepoRepository,
148 protocol: &impl GitProtocolServer,
149) -> Result<Option<GitResponse>> {
150 let Some(org) = orgs.find_by_name(handle).await? else {
151 return Ok(None);
152 };
153
154 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
155 return Ok(None);
156 };
157
158 // Existence is settled before permission, so a push to a repository the actor may
159 // not even see answers "no such repository" rather than "not allowed" — the latter
160 // would confirm it exists.
161 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
162 return Ok(None);
163 }
164
165 let writing = endpoint.service().operation() == GitOperation::Write;
166
167 if writing && !is_org_owner(&org, actor, memberships).await? {
168 return Err(DomainError::Forbidden.into());
169 }
170
171 if writing {
172 // **Authorized write path only.** Every refusal above has already returned, and a
173 // read never reaches here, so nothing a clone does can move a repository up the
174 // profile.
175 //
176 // This records that a push was *authorized*, not that it succeeded: the pack may
177 // still be rejected by the backend, by a hook, or by the client hanging up. The
178 // honest alternative is to wait for the subprocess and inspect its exit — a far
179 // larger change (the response streams, so nothing here sees the end of it) for a
180 // small gain in accuracy on a timestamp that only orders a listing.
181 //
182 // A failure here must not fail the push, exactly as a directory that will not
183 // delete does not fail `delete_repo`: the push is the thing the user asked for,
184 // and a stale sort key is not worth refusing it over.
185 if let Err(error) = repos.touch(&repo.id, SystemTime::now()).await {
186 eprintln!(
187 "steid: push to {}/{} authorized, but its updated_at could not be moved: {error}",
188 org.name, repo.name
189 );
190 }
191 }
192
193 let service = endpoint.service();
194 let path_info = format!("/{}/{}.git", org.name, repo.name);
195
196 // Built here from validated values rather than forwarded from the URL. `OrgName` and
197 // `RepoName` already made traversal impossible, so the backend cannot be pointed at
198 // a path this function did not agree to.
199 let (path_info, query) = match endpoint {
200 GitEndpoint::Advertisement(_) => (
201 format!("{path_info}/info/refs"),
202 format!("service={}", service.as_str()),
203 ),
204 GitEndpoint::Rpc(_) => (format!("{path_info}/{}", service.as_str()), String::new()),
205 };
206
207 let response = protocol
208 .serve(GitRequest {
209 method: endpoint.method(),
210 path_info,
211 query,
212 content_type: headers.content_type,
213 content_encoding: headers.content_encoding,
214 content_length: headers.content_length,
215 git_protocol: headers.git_protocol,
216 // Past the gate above, so this is only ever true for a write that was
217 // authorized. Left false for a read, which never needs it.
218 allow_receive_pack: writing,
219 body,
220 })
221 .await?;
222
223 Ok(Some(response))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::{
230 domain::{
231 Membership, MembershipId, OrgId, Organization, RepoId, Repository, Role, UserId,
232 Visibility,
233 },
234 infrastructure::{
235 git::InMemoryGitProtocol,
236 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
237 },
238 };
239
240 /// When the fixture's repositories were created. Fixed, so a test can tell a
241 /// timestamp that moved from one that never did without racing the clock.
242 const CREATED: SystemTime = SystemTime::UNIX_EPOCH;
243
244 struct Fixture {
245 orgs: InMemoryOrgRepo,
246 memberships: InMemoryMembershipRepo,
247 repos: InMemoryRepoRepo,
248 protocol: InMemoryGitProtocol,
249 owner: Actor,
250 member: Actor,
251 stranger: Actor,
252 handle: OrgName,
253 }
254
255 async fn fixture() -> Fixture {
256 let orgs = InMemoryOrgRepo::new();
257 let memberships = InMemoryMembershipRepo::new();
258
259 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
260 orgs.save(&org).await.expect("save org");
261
262 let owner = UserId::generate();
263 let member = UserId::generate();
264
265 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
266 memberships
267 .save(&Membership::new(
268 MembershipId::generate(),
269 org.id.clone(),
270 user.clone(),
271 role,
272 ))
273 .await
274 .expect("save membership");
275 }
276
277 let repos = InMemoryRepoRepo::new();
278 for (name, visibility) in [
279 ("steid", Visibility::Public),
280 ("secret", Visibility::Private),
281 ] {
282 repos
283 .save(
284 &Repository::new(
285 RepoId::generate(),
286 org.id.clone(),
287 name,
288 None,
289 visibility,
290 CREATED,
291 )
292 .expect("valid repo"),
293 )
294 .await
295 .expect("save repo");
296 }
297
298 Fixture {
299 orgs,
300 memberships,
301 repos,
302 protocol: InMemoryGitProtocol::new(),
303 owner: Actor::User(owner),
304 member: Actor::User(member),
305 stranger: Actor::User(UserId::generate()),
306 handle: org.name,
307 }
308 }
309
310 impl Fixture {
311 async fn serve(
312 &self,
313 actor: &Actor,
314 name: &str,
315 endpoint: GitEndpoint,
316 ) -> Result<Option<GitResponse>> {
317 self.serve_with(actor, name, endpoint, GitClientHeaders::default())
318 .await
319 }
320
321 async fn serve_with(
322 &self,
323 actor: &Actor,
324 name: &str,
325 endpoint: GitEndpoint,
326 headers: GitClientHeaders,
327 ) -> Result<Option<GitResponse>> {
328 serve_git(
329 &self.handle,
330 &RepoName::new(name).expect("valid name"),
331 endpoint,
332 headers,
333 Box::pin(tokio::io::empty()),
334 actor,
335 &self.orgs,
336 &self.memberships,
337 &self.repos,
338 &self.protocol,
339 )
340 .await
341 }
342 }
343
344 fn advertise_clone() -> GitEndpoint {
345 GitEndpoint::Advertisement(GitService::UploadPack)
346 }
347
348 fn advertise_push() -> GitEndpoint {
349 GitEndpoint::Advertisement(GitService::ReceivePack)
350 }
351
352 // --- what reaches the backend ----------------------------------------------
353
354 #[tokio::test]
355 async fn an_advertisement_is_addressed_to_the_repository_on_disk() {
356 // The URL says `/acme/repos/steid.git`; the disk says `acme/steid.git`. The use
357 // case builds the second from validated values rather than trusting the first.
358 let f = fixture().await;
359
360 f.serve(&Actor::Anonymous, "steid", advertise_clone())
361 .await
362 .expect("should serve")
363 .expect("should be visible");
364
365 let request = f.protocol.requests().pop().expect("backend was reached");
366 assert_eq!(request.path_info, "/acme/steid.git/info/refs");
367 assert_eq!(request.query, "service=git-upload-pack");
368 assert_eq!(request.method, GitMethod::Get);
369 }
370
371 #[tokio::test]
372 async fn an_rpc_posts_to_the_service_with_no_query() {
373 let f = fixture().await;
374
375 f.serve(
376 &Actor::Anonymous,
377 "steid",
378 GitEndpoint::Rpc(GitService::UploadPack),
379 )
380 .await
381 .expect("should serve")
382 .expect("should be visible");
383
384 let request = f.protocol.requests().pop().expect("backend was reached");
385 assert_eq!(request.path_info, "/acme/steid.git/git-upload-pack");
386 assert_eq!(request.query, "");
387 assert_eq!(request.method, GitMethod::Post);
388 }
389
390 #[tokio::test]
391 async fn the_headers_that_change_gits_behaviour_are_forwarded() {
392 // Both of these fail silently when dropped: one downgrades the protocol, the
393 // other hands git a compressed body it will not recognise.
394 let f = fixture().await;
395
396 f.serve_with(
397 &Actor::Anonymous,
398 "steid",
399 GitEndpoint::Rpc(GitService::UploadPack),
400 GitClientHeaders {
401 content_encoding: Some("gzip".to_owned()),
402 git_protocol: Some("version=2".to_owned()),
403 ..GitClientHeaders::default()
404 },
405 )
406 .await
407 .expect("should serve")
408 .expect("should be visible");
409
410 let request = f.protocol.requests().pop().expect("backend was reached");
411 assert_eq!(request.content_encoding.as_deref(), Some("gzip"));
412 assert_eq!(request.git_protocol.as_deref(), Some("version=2"));
413 }
414
415 // --- visibility --------------------------------------------------------------
416
417 #[tokio::test]
418 async fn a_public_repository_is_clonable_by_anyone() {
419 let f = fixture().await;
420
421 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
422 assert!(
423 f.serve(actor, "steid", advertise_clone())
424 .await
425 .expect("should serve")
426 .is_some(),
427 "{actor:?} should be able to clone a public repository"
428 );
429 }
430 }
431
432 #[tokio::test]
433 async fn a_private_repository_is_absent_for_outsiders_and_never_reaches_git() {
434 // `None`, not an error, and — the part worth testing — no bytes flow. A refusal
435 // that arrives after the backend is spawned is not a refusal.
436 let f = fixture().await;
437
438 for actor in [&Actor::Anonymous, &f.stranger] {
439 assert!(
440 f.serve(actor, "secret", advertise_clone())
441 .await
442 .expect("should serve")
443 .is_none(),
444 "{actor:?} should not see a private repository"
445 );
446 }
447
448 assert!(!f.protocol.was_called());
449 }
450
451 #[tokio::test]
452 async fn a_private_repository_is_clonable_by_any_member() {
453 let f = fixture().await;
454
455 for actor in [&f.member, &f.owner] {
456 assert!(
457 f.serve(actor, "secret", advertise_clone())
458 .await
459 .expect("should serve")
460 .is_some(),
461 "{actor:?} should be able to clone a private repository"
462 );
463 }
464 }
465
466 #[tokio::test]
467 async fn an_unknown_repository_is_absent() {
468 let f = fixture().await;
469
470 assert!(
471 f.serve(&f.owner, "nothing-here", advertise_clone())
472 .await
473 .expect("should serve")
474 .is_none()
475 );
476 assert!(!f.protocol.was_called());
477 }
478
479 #[tokio::test]
480 async fn an_unknown_handle_is_absent() {
481 let f = fixture().await;
482 let missing = OrgName::new("nobody").expect("valid handle");
483
484 let served = serve_git(
485 &missing,
486 &RepoName::new("steid").expect("valid"),
487 advertise_clone(),
488 GitClientHeaders::default(),
489 Box::pin(tokio::io::empty()),
490 &f.owner,
491 &f.orgs,
492 &f.memberships,
493 &f.repos,
494 &f.protocol,
495 )
496 .await
497 .expect("should serve");
498
499 assert!(served.is_none());
500 assert!(!f.protocol.was_called());
501 }
502
503 // --- writes ------------------------------------------------------------------
504
505 #[tokio::test]
506 async fn receive_pack_is_enabled_only_for_an_authorized_write() {
507 // The flag git keys off. A read must never turn it on, and a refused write must
508 // never reach the backend at all — so the only true is a push that passed.
509 let f = fixture().await;
510
511 f.serve(&Actor::Anonymous, "steid", advertise_clone())
512 .await
513 .expect("should serve")
514 .expect("visible");
515 assert!(!f.protocol.requests()[0].allow_receive_pack);
516
517 f.serve(&f.owner, "steid", advertise_push())
518 .await
519 .expect("should serve")
520 .expect("allowed");
521 assert!(f.protocol.requests()[1].allow_receive_pack);
522 }
523
524 #[tokio::test]
525 async fn the_owner_may_push() {
526 let f = fixture().await;
527
528 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
529 f.serve(&f.owner, "steid", endpoint)
530 .await
531 .expect("should serve")
532 .expect("should be allowed");
533 }
534
535 assert_eq!(f.protocol.requests().len(), 2);
536 }
537
538 #[tokio::test]
539 async fn the_owner_may_push_to_a_private_repository() {
540 let f = fixture().await;
541
542 f.serve(&f.owner, "secret", advertise_push())
543 .await
544 .expect("should serve")
545 .expect("should be allowed");
546 }
547
548 #[tokio::test]
549 async fn a_member_who_is_not_the_owner_may_not_push() {
550 // Read access is not permission to rewrite history. The same rule as creating a
551 // repository, and the same rule attempt #2 shipped over SSH.
552 let f = fixture().await;
553
554 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
555 let error = f
556 .serve(&f.member, "steid", endpoint)
557 .await
558 .expect_err("push should be refused");
559
560 assert!(
561 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
562 "a member on {endpoint:?} should be forbidden, got {error:?}"
563 );
564 }
565
566 assert!(!f.protocol.was_called());
567 }
568
569 #[tokio::test]
570 async fn strangers_and_anonymous_callers_may_not_push() {
571 let f = fixture().await;
572
573 for actor in [&Actor::Anonymous, &f.stranger] {
574 let error = f
575 .serve(actor, "steid", advertise_push())
576 .await
577 .expect_err("push should be refused");
578
579 assert!(
580 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
581 "{actor:?} should be forbidden"
582 );
583 }
584
585 assert!(!f.protocol.was_called());
586 }
587
588 #[tokio::test]
589 async fn a_refused_push_never_reaches_git() {
590 // The ordering that matters: once pack data is moving, refusing is not an option.
591 let f = fixture().await;
592
593 let _ = f.serve(&f.member, "steid", advertise_push()).await;
594
595 assert!(!f.protocol.was_called());
596 }
597
598 #[tokio::test]
599 async fn a_push_to_an_invisible_repository_is_absent_rather_than_forbidden() {
600 // Order matters: existence is settled before permission. Answering "forbidden"
601 // here would confirm that a private repository by that name exists.
602 let f = fixture().await;
603
604 let served = f
605 .serve(&Actor::Anonymous, "secret", advertise_push())
606 .await
607 .expect("should serve");
608
609 assert!(served.is_none());
610 }
611
612 // --- recency ------------------------------------------------------------------
613
614 impl Fixture {
615 async fn updated_at(&self, name: &str) -> SystemTime {
616 let org = self
617 .orgs
618 .find_by_name(&self.handle)
619 .await
620 .expect("lookup")
621 .expect("the handle exists");
622
623 self.repos
624 .find_by_org_and_name(&org.id, &RepoName::new(name).expect("valid name"))
625 .await
626 .expect("lookup")
627 .expect("the repository exists")
628 .updated_at
629 }
630 }
631
632 #[tokio::test]
633 async fn an_authorized_push_dates_the_repository() {
634 // What the profile orders by. Recorded when the push is *authorized*, so it is
635 // set before a byte of pack data moves.
636 let f = fixture().await;
637 let before = SystemTime::now();
638
639 f.serve(&f.owner, "steid", advertise_push())
640 .await
641 .expect("should serve")
642 .expect("should be allowed");
643
644 let updated = f.updated_at("steid").await;
645 assert!(updated > CREATED, "the timestamp should have moved");
646 assert!(updated >= before && updated <= SystemTime::now());
647 }
648
649 #[tokio::test]
650 async fn a_clone_does_not_date_the_repository() {
651 // A portfolio ordered by who read it last would reorder itself under a crawler.
652 let f = fixture().await;
653
654 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
655 f.serve(actor, "steid", advertise_clone())
656 .await
657 .expect("should serve")
658 .expect("visible");
659 f.serve(actor, "steid", GitEndpoint::Rpc(GitService::UploadPack))
660 .await
661 .expect("should serve")
662 .expect("visible");
663 }
664
665 assert_eq!(f.updated_at("steid").await, CREATED);
666 }
667
668 #[tokio::test]
669 async fn a_refused_push_does_not_date_the_repository() {
670 // Otherwise anyone at all could reorder someone's profile by attempting a push.
671 let f = fixture().await;
672
673 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
674 let _ = f.serve(actor, "steid", advertise_push()).await;
675 }
676
677 assert_eq!(f.updated_at("steid").await, CREATED);
678 }
679
680 #[tokio::test]
681 async fn a_push_to_a_repository_the_actor_cannot_see_dates_nothing() {
682 // The visibility gate returns before the touch, so a stranger cannot even prove
683 // the private repository exists by watching the profile reorder.
684 let f = fixture().await;
685
686 assert!(
687 f.serve(&Actor::Anonymous, "secret", advertise_push())
688 .await
689 .expect("should serve")
690 .is_none()
691 );
692
693 assert_eq!(f.updated_at("secret").await, CREATED);
694 }
695
696 // --- service parsing ---------------------------------------------------------
697
698 #[tokio::test]
699 async fn a_service_round_trips_through_its_wire_name() {
700 for service in [GitService::UploadPack, GitService::ReceivePack] {
701 assert_eq!(
702 service
703 .as_str()
704 .parse::<GitService>()
705 .expect("known service"),
706 service
707 );
708 }
709 }
710
711 #[tokio::test]
712 async fn an_unknown_service_is_an_error_not_a_default() {
713 assert!("git-do-whatever".parse::<GitService>().is_err());
714 assert!("".parse::<GitService>().is_err());
715 }
716
717 #[tokio::test]
718 async fn only_upload_pack_reads() {
719 assert_eq!(GitService::UploadPack.operation(), GitOperation::Read);
720 assert_eq!(GitService::ReceivePack.operation(), GitOperation::Write);
721 }
722}