@jpgilldev / steid

21.0 KBRaw
1//! Reading a repository's contents for display.
2//!
3//! Authorization is not re-implemented here: every entry point goes through
4//! [`view_repo`](super::repo::view_repo), so a repository invisible on its page is
5//! invisible in its file tree, by construction rather than by remembering to check.
6
7use crate::domain::{
8 Actor, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
9 repository::{MembershipRepository, OrgRepository, RepoRepository},
10};
11
12use super::{
13 error::Result,
14 port::{Blob, GitQuery},
15 repo::view_repo,
16};
17
18/// The largest file Steid will render.
19///
20/// A page has a person waiting on it, and past a megabyte nobody is reading the file —
21/// they are waiting for a browser to lay out a megabyte of text. Bigger files are
22/// reported by size rather than shown.
23pub const MAX_BLOB_BYTES: u64 = 1024 * 1024;
24
25/// How many commits a log shows. No paging in v1; this is the whole of it.
26pub const LOG_LIMIT: usize = 50;
27
28/// The largest file Steid will hand back raw.
29///
30/// Much larger than [`MAX_BLOB_BYTES`], because nobody is reading a raw response — it is
31/// being saved or piped, and the megabyte cap exists to protect a *browser*. It is still
32/// capped, and capped well below what a repository can hold, because [`GitQuery`] reads
33/// bytes rather than streaming them: this number is the memory one request may cost, so
34/// it bounds what a handful of concurrent requests can do to a small VPS. Anything
35/// larger is what `git clone` is for.
36pub const MAX_RAW_BYTES: u64 = 10 * 1024 * 1024;
37
38/// A file, as far as it can be displayed.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct FileView {
41 pub id: ObjectId,
42 pub size: u64,
43 /// The contents, when they are text small enough to show.
44 ///
45 /// `None` covers both "not valid UTF-8" and "too large"; [`too_large`](Self::too_large)
46 /// tells them apart, because the page says something different for each.
47 pub text: Option<String>,
48 pub too_large: bool,
49}
50
51impl FileView {
52 /// Whether the file exists and is simply not displayable as text.
53 pub fn is_binary(&self) -> bool {
54 self.text.is_none() && !self.too_large
55 }
56}
57
58/// What is at a path in a repository.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Browsed {
61 /// The repository has no commits. Distinct from an empty directory: there is
62 /// nothing to point a revision at, so the page offers push instructions rather than
63 /// an empty listing.
64 Empty,
65 Directory {
66 rev: RefName,
67 path: RepoPath,
68 /// Ordered directories-first, then case-insensitively by name.
69 entries: Vec<crate::domain::TreeEntry>,
70 },
71 File {
72 rev: RefName,
73 path: RepoPath,
74 file: FileView,
75 },
76}
77
78/// Resolves a path in a repository into whatever is there.
79///
80/// `rev` of `None` means the default branch, which is what a bare repository URL asks
81/// for.
82///
83/// `Ok(None)` means the repository is invisible, absent, or has nothing at that path —
84/// all rendered identically as 404, for the reason
85/// [`view_repo`](super::repo::view_repo) gives.
86#[allow(clippy::too_many_arguments)]
87pub async fn browse_repo(
88 handle: &OrgName,
89 name: &RepoName,
90 rev: Option<&RefName>,
91 path: &RepoPath,
92 actor: &Actor,
93 orgs: &impl OrgRepository,
94 memberships: &impl MembershipRepository,
95 repos: &impl RepoRepository,
96 queries: &impl GitQuery,
97) -> Result<Option<Browsed>> {
98 if view_repo(handle, name, actor, orgs, memberships, repos)
99 .await?
100 .is_none()
101 {
102 return Ok(None);
103 }
104
105 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
106 return Ok(Some(Browsed::Empty));
107 };
108
109 // A directory first, because that is the common case and the cheaper question.
110 if let Some(mut entries) = queries.list_tree(handle, name, &rev, path).await? {
111 entries.sort_by_key(crate::domain::TreeEntry::ordering_key);
112
113 return Ok(Some(Browsed::Directory {
114 rev,
115 path: path.clone(),
116 entries,
117 }));
118 }
119
120 let Some(blob) = queries
121 .read_blob(handle, name, &rev, path, MAX_BLOB_BYTES)
122 .await?
123 else {
124 return Ok(None);
125 };
126
127 Ok(Some(Browsed::File {
128 rev,
129 path: path.clone(),
130 file: view_of(blob),
131 }))
132}
133
134/// The commit log for a revision, newest first.
135///
136/// `Ok(None)` on the same terms as [`browse_repo`]. An empty repository logs nothing
137/// rather than failing.
138#[allow(clippy::too_many_arguments)]
139pub async fn repo_log(
140 handle: &OrgName,
141 name: &RepoName,
142 rev: Option<&RefName>,
143 actor: &Actor,
144 orgs: &impl OrgRepository,
145 memberships: &impl MembershipRepository,
146 repos: &impl RepoRepository,
147 queries: &impl GitQuery,
148) -> Result<Option<Vec<CommitSummary>>> {
149 if view_repo(handle, name, actor, orgs, memberships, repos)
150 .await?
151 .is_none()
152 {
153 return Ok(None);
154 }
155
156 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
157 return Ok(Some(Vec::new()));
158 };
159
160 Ok(Some(queries.log(handle, name, &rev, LOG_LIMIT).await?))
161}
162
163/// The branches and tags a repository has, ready for a switcher.
164///
165/// Two lists rather than one tagged list, because that is how they are shown: a
166/// visitor picking a revision is picking from branches *or* from tags, and the same
167/// name can legitimately appear in both.
168#[derive(Debug, Clone, Default, PartialEq, Eq)]
169pub struct RefList {
170 pub branches: Vec<RefName>,
171 pub tags: Vec<RefName>,
172}
173
174impl RefList {
175 pub fn is_empty(&self) -> bool {
176 self.branches.is_empty() && self.tags.is_empty()
177 }
178
179 /// Whether a revision names one of these refs.
180 ///
181 /// What a switcher uses to decide whether the current revision is a ref it can
182 /// highlight or an object id it has to show as itself.
183 pub fn contains(&self, rev: &RefName) -> bool {
184 self.branches
185 .iter()
186 .chain(&self.tags)
187 .any(|name| name == rev)
188 }
189}
190
191/// Every branch and tag, for the revision switcher.
192///
193/// `Ok(None)` on the same terms as [`browse_repo`]: invisible and absent are one answer.
194///
195/// **This costs one extra `git` process (~14ms) on top of whatever the page already
196/// spends**, so it is called by the pages that show a switcher and by nothing else. See
197/// the port's note on [`list_refs`](super::port::GitQuery::list_refs).
198///
199/// Ordering is decided here rather than in an adapter: branches then tags, each
200/// case-insensitively by name, with ties broken by the name itself so the order is
201/// total. The default branch is not floated to the top — it is usually first
202/// alphabetically anyway, and a list that reorders itself is harder to scan than one
203/// that does not.
204pub async fn list_refs(
205 handle: &OrgName,
206 name: &RepoName,
207 actor: &Actor,
208 orgs: &impl OrgRepository,
209 memberships: &impl MembershipRepository,
210 repos: &impl RepoRepository,
211 queries: &impl GitQuery,
212) -> Result<Option<RefList>> {
213 if view_repo(handle, name, actor, orgs, memberships, repos)
214 .await?
215 .is_none()
216 {
217 return Ok(None);
218 }
219
220 let mut list = RefList::default();
221
222 for git_ref in queries.list_refs(handle, name).await? {
223 match git_ref.kind {
224 RefKind::Branch => list.branches.push(git_ref.name),
225 RefKind::Tag => list.tags.push(git_ref.name),
226 }
227 }
228
229 for names in [&mut list.branches, &mut list.tags] {
230 names.sort_by(|left, right| {
231 left.as_str()
232 .to_lowercase()
233 .cmp(&right.as_str().to_lowercase())
234 .then_with(|| left.as_str().cmp(right.as_str()))
235 });
236 }
237
238 Ok(Some(list))
239}
240
241/// A file as it is served rather than rendered.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum RawFile {
244 Ready {
245 /// The file's own name, for the download it becomes.
246 name: String,
247 content: Vec<u8>,
248 },
249 /// Bigger than [`MAX_RAW_BYTES`]. Reported rather than served, because the port
250 /// reads bytes into memory; the size is carried so the refusal can say why.
251 TooLarge { size: u64 },
252}
253
254/// Reads a file for serving verbatim.
255///
256/// Authorized exactly as [`browse_repo`] is, through [`view_repo`], so a repository
257/// invisible on its page is invisible here too — a raw URL is not a side door.
258///
259/// `Ok(None)` for a repository that is invisible or absent, a revision that is not
260/// there, a path that is not there, and a path that is a directory. All of them are one
261/// answer for the reason [`view_repo`] gives, and a directory is included because there
262/// is no such thing as raw bytes for one.
263#[allow(clippy::too_many_arguments)]
264pub async fn read_raw_file(
265 handle: &OrgName,
266 name: &RepoName,
267 rev: Option<&RefName>,
268 path: &RepoPath,
269 actor: &Actor,
270 orgs: &impl OrgRepository,
271 memberships: &impl MembershipRepository,
272 repos: &impl RepoRepository,
273 queries: &impl GitQuery,
274) -> Result<Option<RawFile>> {
275 if view_repo(handle, name, actor, orgs, memberships, repos)
276 .await?
277 .is_none()
278 {
279 return Ok(None);
280 }
281
282 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
283 return Ok(None);
284 };
285
286 // Straight to the blob: unlike a browse, there is no directory case to serve, so
287 // asking `list_tree` first would spend a whole process learning that a path is not
288 // something this endpoint can answer for. `read_blob` already says `None` for a
289 // tree.
290 let Some(blob) = queries
291 .read_blob(handle, name, &rev, path, MAX_RAW_BYTES)
292 .await?
293 else {
294 return Ok(None);
295 };
296
297 let Some(content) = blob.content else {
298 return Ok(Some(RawFile::TooLarge { size: blob.size }));
299 };
300
301 Ok(Some(RawFile::Ready {
302 // A path that resolved to a blob has a last component by construction: the root
303 // is a tree, and `read_blob` refuses it.
304 name: path.file_name().unwrap_or_default().to_owned(),
305 content,
306 }))
307}
308
309/// Settles which revision is being asked about.
310///
311/// `None` out means the repository has no commits at all — not that the revision was
312/// wrong, which surfaces later as nothing being found at the path.
313async fn resolve_revision(
314 handle: &OrgName,
315 name: &RepoName,
316 rev: Option<&RefName>,
317 queries: &impl GitQuery,
318) -> Result<Option<RefName>> {
319 match rev {
320 Some(rev) => Ok(Some(rev.clone())),
321 None => Ok(queries.default_branch(handle, name).await?),
322 }
323}
324
325/// Decides what can be done with a blob's bytes.
326///
327/// The port carries bytes and a size; turning those into "text", "binary" or "too big"
328/// is a display decision, so it happens here rather than in the adapter.
329fn view_of(blob: Blob) -> FileView {
330 let too_large = blob.content.is_none();
331 let text = blob.content.and_then(|bytes| String::from_utf8(bytes).ok());
332
333 FileView {
334 id: blob.id,
335 size: blob.size,
336 text,
337 too_large,
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use std::time::SystemTime;
344
345 use super::*;
346 use crate::{
347 domain::{
348 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
349 },
350 infrastructure::{
351 git::InMemoryGitQuery,
352 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
353 },
354 };
355
356 struct Fixture {
357 orgs: InMemoryOrgRepo,
358 memberships: InMemoryMembershipRepo,
359 repos: InMemoryRepoRepo,
360 handle: OrgName,
361 owner: Actor,
362 stranger: Actor,
363 }
364
365 /// One organisation with an owner, and a `steid` repository of the given visibility.
366 async fn fixture(visibility: Visibility) -> Fixture {
367 let orgs = InMemoryOrgRepo::new();
368 let memberships = InMemoryMembershipRepo::new();
369 let repos = InMemoryRepoRepo::new();
370
371 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
372 orgs.save(&org).await.expect("save org");
373
374 let owner = UserId::generate();
375 memberships
376 .save(&Membership::new(
377 MembershipId::generate(),
378 org.id.clone(),
379 owner.clone(),
380 crate::domain::Role::Owner,
381 ))
382 .await
383 .expect("save membership");
384
385 repos
386 .save(
387 &Repository::new(
388 RepoId::generate(),
389 org.id.clone(),
390 "steid",
391 None,
392 visibility,
393 SystemTime::now(),
394 )
395 .expect("valid repository"),
396 )
397 .await
398 .expect("save repo");
399
400 Fixture {
401 orgs,
402 memberships,
403 repos,
404 handle: org.name,
405 owner: Actor::User(owner),
406 stranger: Actor::Anonymous,
407 }
408 }
409
410 fn repo_name() -> RepoName {
411 RepoName::new("steid").expect("valid repository name")
412 }
413
414 fn rev(value: &str) -> RefName {
415 RefName::new(value).expect("valid revision")
416 }
417
418 fn path(value: &str) -> RepoPath {
419 RepoPath::new(value).expect("valid path")
420 }
421
422 impl Fixture {
423 async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
424 list_refs(
425 &self.handle,
426 &repo_name(),
427 actor,
428 &self.orgs,
429 &self.memberships,
430 &self.repos,
431 queries,
432 )
433 .await
434 }
435
436 async fn raw(
437 &self,
438 actor: &Actor,
439 path: &RepoPath,
440 queries: &InMemoryGitQuery,
441 ) -> Result<Option<RawFile>> {
442 read_raw_file(
443 &self.handle,
444 &repo_name(),
445 Some(&rev("main")),
446 path,
447 actor,
448 &self.orgs,
449 &self.memberships,
450 &self.repos,
451 queries,
452 )
453 .await
454 }
455 }
456
457 // --- list_refs ----------------------------------------------------------------
458
459 #[tokio::test]
460 async fn branches_and_tags_come_back_separated_and_ordered() {
461 let f = fixture(Visibility::Public).await;
462 let queries = InMemoryGitQuery::new()
463 .with_branch("main")
464 .with_branch("Feature")
465 .with_tag("v2.0")
466 .with_tag("v1.0");
467
468 let refs = f
469 .refs(&f.owner, &queries)
470 .await
471 .expect("should read")
472 .expect("visible");
473
474 // Case-insensitively, so `Feature` sorts next to `feature` rather than before
475 // every lowercase name.
476 assert_eq!(
477 refs.branches
478 .iter()
479 .map(RefName::as_str)
480 .collect::<Vec<_>>(),
481 vec!["Feature", "main"]
482 );
483 assert_eq!(
484 refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
485 vec!["v1.0", "v2.0"]
486 );
487 }
488
489 #[tokio::test]
490 async fn an_empty_repository_has_no_refs_to_switch_between() {
491 let f = fixture(Visibility::Public).await;
492
493 let refs = f
494 .refs(&f.owner, &InMemoryGitQuery::empty())
495 .await
496 .expect("should read")
497 .expect("visible");
498
499 assert!(refs.is_empty());
500 }
501
502 #[tokio::test]
503 async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
504 // The ref list names branches, which are content. Same answer as the page.
505 let f = fixture(Visibility::Private).await;
506 let queries = InMemoryGitQuery::new().with_branch("secret-work");
507
508 assert!(
509 f.refs(&f.stranger, &queries)
510 .await
511 .expect("should read")
512 .is_none()
513 );
514 assert!(
515 f.refs(&f.owner, &queries)
516 .await
517 .expect("should read")
518 .is_some()
519 );
520 }
521
522 #[test]
523 fn a_ref_list_knows_the_revision_it_is_showing() {
524 let refs = RefList {
525 branches: vec![RefName::from_trusted("main")],
526 tags: vec![RefName::from_trusted("v1.0")],
527 };
528
529 assert!(refs.contains(&rev("main")));
530 assert!(refs.contains(&rev("v1.0")));
531 // An object id is not a ref, which is what a switcher needs to know before it
532 // tries to highlight one.
533 assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
534 }
535
536 // --- read_raw_file ------------------------------------------------------------
537
538 #[tokio::test]
539 async fn a_text_file_comes_back_with_its_own_name() {
540 let f = fixture(Visibility::Public).await;
541 let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
542
543 let raw = f
544 .raw(&f.owner, &path("src/main.rs"), &queries)
545 .await
546 .expect("should read")
547 .expect("found");
548
549 assert_eq!(
550 raw,
551 RawFile::Ready {
552 name: "main.rs".to_owned(),
553 content: b"fn main() {}\n".to_vec(),
554 }
555 );
556 }
557
558 #[tokio::test]
559 async fn a_binary_file_comes_back_byte_for_byte() {
560 // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
561 let f = fixture(Visibility::Public).await;
562 let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
563 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
564
565 let raw = f
566 .raw(&f.owner, &path("logo.png"), &queries)
567 .await
568 .expect("should read")
569 .expect("found");
570
571 match raw {
572 RawFile::Ready { content, .. } => assert_eq!(content, bytes),
573 other => panic!("expected the bytes, got {other:?}"),
574 }
575 }
576
577 #[tokio::test]
578 async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
579 let f = fixture(Visibility::Public).await;
580 let size = MAX_RAW_BYTES as usize + 1;
581 let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
582
583 let raw = f
584 .raw(&f.owner, &path("huge.bin"), &queries)
585 .await
586 .expect("should read")
587 .expect("found");
588
589 assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
590 }
591
592 #[tokio::test]
593 async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
594 // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
595 // these bytes in a browser, so the reason for the page's limit does not apply.
596 let f = fixture(Visibility::Public).await;
597 let size = MAX_BLOB_BYTES as usize + 1;
598 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
599
600 let raw = f
601 .raw(&f.owner, &path("big.txt"), &queries)
602 .await
603 .expect("should read")
604 .expect("found");
605
606 match raw {
607 RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
608 other => panic!("expected the bytes, got {other:?}"),
609 }
610 }
611
612 #[tokio::test]
613 async fn a_directory_has_no_raw_bytes() {
614 // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
615 // not a file, and there is nothing to serve.
616 let f = fixture(Visibility::Public).await;
617 let queries = InMemoryGitQuery::new()
618 .with_tree("main", "src", Vec::new())
619 .with_blob("main", "src/main.rs", b"fn main() {}\n");
620
621 assert!(
622 f.raw(&f.owner, &path("src"), &queries)
623 .await
624 .expect("should read")
625 .is_none()
626 );
627 }
628
629 #[tokio::test]
630 async fn a_path_that_is_not_there_is_not_found() {
631 let f = fixture(Visibility::Public).await;
632 let queries = InMemoryGitQuery::new();
633
634 assert!(
635 f.raw(&f.owner, &path("nope.txt"), &queries)
636 .await
637 .expect("should read")
638 .is_none()
639 );
640 }
641
642 #[tokio::test]
643 async fn an_empty_repository_serves_nothing_raw() {
644 let f = fixture(Visibility::Public).await;
645
646 assert!(
647 f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
648 .await
649 .expect("should read")
650 .is_none()
651 );
652 }
653
654 #[tokio::test]
655 async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
656 // The point of the endpoint's authorization: a raw URL is not a way around the
657 // page's answer.
658 let f = fixture(Visibility::Private).await;
659 let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
660
661 assert!(
662 f.raw(&f.stranger, &path("secret.txt"), &queries)
663 .await
664 .expect("should read")
665 .is_none()
666 );
667 assert!(
668 f.raw(&f.owner, &path("secret.txt"), &queries)
669 .await
670 .expect("should read")
671 .is_some()
672 );
673 }
674}