steid

@jamesgill /

20.9 KBCode·Blame·Raw
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 super::*;
344 use crate::{
345 domain::{
346 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
347 },
348 infrastructure::{
349 git::InMemoryGitQuery,
350 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
351 },
352 };
353
354 struct Fixture {
355 orgs: InMemoryOrgRepo,
356 memberships: InMemoryMembershipRepo,
357 repos: InMemoryRepoRepo,
358 handle: OrgName,
359 owner: Actor,
360 stranger: Actor,
361 }
362
363 /// One organisation with an owner, and a `steid` repository of the given visibility.
364 async fn fixture(visibility: Visibility) -> Fixture {
365 let orgs = InMemoryOrgRepo::new();
366 let memberships = InMemoryMembershipRepo::new();
367 let repos = InMemoryRepoRepo::new();
368
369 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
370 orgs.save(&org).await.expect("save org");
371
372 let owner = UserId::generate();
373 memberships
374 .save(&Membership::new(
375 MembershipId::generate(),
376 org.id.clone(),
377 owner.clone(),
378 crate::domain::Role::Owner,
379 ))
380 .await
381 .expect("save membership");
382
383 repos
384 .save(
385 &Repository::new(
386 RepoId::generate(),
387 org.id.clone(),
388 "steid",
389 None,
390 visibility,
391 )
392 .expect("valid repository"),
393 )
394 .await
395 .expect("save repo");
396
397 Fixture {
398 orgs,
399 memberships,
400 repos,
401 handle: org.name,
402 owner: Actor::User(owner),
403 stranger: Actor::Anonymous,
404 }
405 }
406
407 fn repo_name() -> RepoName {
408 RepoName::new("steid").expect("valid repository name")
409 }
410
411 fn rev(value: &str) -> RefName {
412 RefName::new(value).expect("valid revision")
413 }
414
415 fn path(value: &str) -> RepoPath {
416 RepoPath::new(value).expect("valid path")
417 }
418
419 impl Fixture {
420 async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
421 list_refs(
422 &self.handle,
423 &repo_name(),
424 actor,
425 &self.orgs,
426 &self.memberships,
427 &self.repos,
428 queries,
429 )
430 .await
431 }
432
433 async fn raw(
434 &self,
435 actor: &Actor,
436 path: &RepoPath,
437 queries: &InMemoryGitQuery,
438 ) -> Result<Option<RawFile>> {
439 read_raw_file(
440 &self.handle,
441 &repo_name(),
442 Some(&rev("main")),
443 path,
444 actor,
445 &self.orgs,
446 &self.memberships,
447 &self.repos,
448 queries,
449 )
450 .await
451 }
452 }
453
454 // --- list_refs ----------------------------------------------------------------
455
456 #[tokio::test]
457 async fn branches_and_tags_come_back_separated_and_ordered() {
458 let f = fixture(Visibility::Public).await;
459 let queries = InMemoryGitQuery::new()
460 .with_branch("main")
461 .with_branch("Feature")
462 .with_tag("v2.0")
463 .with_tag("v1.0");
464
465 let refs = f
466 .refs(&f.owner, &queries)
467 .await
468 .expect("should read")
469 .expect("visible");
470
471 // Case-insensitively, so `Feature` sorts next to `feature` rather than before
472 // every lowercase name.
473 assert_eq!(
474 refs.branches
475 .iter()
476 .map(RefName::as_str)
477 .collect::<Vec<_>>(),
478 vec!["Feature", "main"]
479 );
480 assert_eq!(
481 refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
482 vec!["v1.0", "v2.0"]
483 );
484 }
485
486 #[tokio::test]
487 async fn an_empty_repository_has_no_refs_to_switch_between() {
488 let f = fixture(Visibility::Public).await;
489
490 let refs = f
491 .refs(&f.owner, &InMemoryGitQuery::empty())
492 .await
493 .expect("should read")
494 .expect("visible");
495
496 assert!(refs.is_empty());
497 }
498
499 #[tokio::test]
500 async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
501 // The ref list names branches, which are content. Same answer as the page.
502 let f = fixture(Visibility::Private).await;
503 let queries = InMemoryGitQuery::new().with_branch("secret-work");
504
505 assert!(
506 f.refs(&f.stranger, &queries)
507 .await
508 .expect("should read")
509 .is_none()
510 );
511 assert!(
512 f.refs(&f.owner, &queries)
513 .await
514 .expect("should read")
515 .is_some()
516 );
517 }
518
519 #[test]
520 fn a_ref_list_knows_the_revision_it_is_showing() {
521 let refs = RefList {
522 branches: vec![RefName::from_trusted("main")],
523 tags: vec![RefName::from_trusted("v1.0")],
524 };
525
526 assert!(refs.contains(&rev("main")));
527 assert!(refs.contains(&rev("v1.0")));
528 // An object id is not a ref, which is what a switcher needs to know before it
529 // tries to highlight one.
530 assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
531 }
532
533 // --- read_raw_file ------------------------------------------------------------
534
535 #[tokio::test]
536 async fn a_text_file_comes_back_with_its_own_name() {
537 let f = fixture(Visibility::Public).await;
538 let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
539
540 let raw = f
541 .raw(&f.owner, &path("src/main.rs"), &queries)
542 .await
543 .expect("should read")
544 .expect("found");
545
546 assert_eq!(
547 raw,
548 RawFile::Ready {
549 name: "main.rs".to_owned(),
550 content: b"fn main() {}\n".to_vec(),
551 }
552 );
553 }
554
555 #[tokio::test]
556 async fn a_binary_file_comes_back_byte_for_byte() {
557 // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
558 let f = fixture(Visibility::Public).await;
559 let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
560 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
561
562 let raw = f
563 .raw(&f.owner, &path("logo.png"), &queries)
564 .await
565 .expect("should read")
566 .expect("found");
567
568 match raw {
569 RawFile::Ready { content, .. } => assert_eq!(content, bytes),
570 other => panic!("expected the bytes, got {other:?}"),
571 }
572 }
573
574 #[tokio::test]
575 async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
576 let f = fixture(Visibility::Public).await;
577 let size = MAX_RAW_BYTES as usize + 1;
578 let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
579
580 let raw = f
581 .raw(&f.owner, &path("huge.bin"), &queries)
582 .await
583 .expect("should read")
584 .expect("found");
585
586 assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
587 }
588
589 #[tokio::test]
590 async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
591 // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
592 // these bytes in a browser, so the reason for the page's limit does not apply.
593 let f = fixture(Visibility::Public).await;
594 let size = MAX_BLOB_BYTES as usize + 1;
595 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
596
597 let raw = f
598 .raw(&f.owner, &path("big.txt"), &queries)
599 .await
600 .expect("should read")
601 .expect("found");
602
603 match raw {
604 RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
605 other => panic!("expected the bytes, got {other:?}"),
606 }
607 }
608
609 #[tokio::test]
610 async fn a_directory_has_no_raw_bytes() {
611 // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
612 // not a file, and there is nothing to serve.
613 let f = fixture(Visibility::Public).await;
614 let queries = InMemoryGitQuery::new()
615 .with_tree("main", "src", Vec::new())
616 .with_blob("main", "src/main.rs", b"fn main() {}\n");
617
618 assert!(
619 f.raw(&f.owner, &path("src"), &queries)
620 .await
621 .expect("should read")
622 .is_none()
623 );
624 }
625
626 #[tokio::test]
627 async fn a_path_that_is_not_there_is_not_found() {
628 let f = fixture(Visibility::Public).await;
629 let queries = InMemoryGitQuery::new();
630
631 assert!(
632 f.raw(&f.owner, &path("nope.txt"), &queries)
633 .await
634 .expect("should read")
635 .is_none()
636 );
637 }
638
639 #[tokio::test]
640 async fn an_empty_repository_serves_nothing_raw() {
641 let f = fixture(Visibility::Public).await;
642
643 assert!(
644 f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
645 .await
646 .expect("should read")
647 .is_none()
648 );
649 }
650
651 #[tokio::test]
652 async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
653 // The point of the endpoint's authorization: a raw URL is not a way around the
654 // page's answer.
655 let f = fixture(Visibility::Private).await;
656 let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
657
658 assert!(
659 f.raw(&f.stranger, &path("secret.txt"), &queries)
660 .await
661 .expect("should read")
662 .is_none()
663 );
664 assert!(
665 f.raw(&f.owner, &path("secret.txt"), &queries)
666 .await
667 .expect("should read")
668 .is_some()
669 );
670 }
671}