steid

@jamesgill /

29.5 KBCode·Blame·Raw
dce0bf3feat: browse a repository's files and history8d
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::{
7ba8b8efeat: the branches and tags a page shows, and what it says when there are none19h
8 Actor, BranchRow, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
9 TagRow,
dce0bf3feat: browse a repository's files and history8d
10 repository::{MembershipRepository, OrgRepository, RepoRepository},
11};
12
13use super::{
14 error::Result,
15 port::{Blob, GitQuery},
16 repo::view_repo,
17};
18
19/// The largest file Steid will render.
20///
21/// A page has a person waiting on it, and past a megabyte nobody is reading the file —
22/// they are waiting for a browser to lay out a megabyte of text. Bigger files are
23/// reported by size rather than shown.
24pub const MAX_BLOB_BYTES: u64 = 1024 * 1024;
25
26/// How many commits a log shows. No paging in v1; this is the whole of it.
27pub const LOG_LIMIT: usize = 50;
28
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
29/// The largest file Steid will hand back raw.
30///
31/// Much larger than [`MAX_BLOB_BYTES`], because nobody is reading a raw response — it is
32/// being saved or piped, and the megabyte cap exists to protect a *browser*. It is still
33/// capped, and capped well below what a repository can hold, because [`GitQuery`] reads
34/// bytes rather than streaming them: this number is the memory one request may cost, so
35/// it bounds what a handful of concurrent requests can do to a small VPS. Anything
36/// larger is what `git clone` is for.
37pub const MAX_RAW_BYTES: u64 = 10 * 1024 * 1024;
38
dce0bf3feat: browse a repository's files and history8d
39/// A file, as far as it can be displayed.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FileView {
42 pub id: ObjectId,
43 pub size: u64,
44 /// The contents, when they are text small enough to show.
45 ///
46 /// `None` covers both "not valid UTF-8" and "too large"; [`too_large`](Self::too_large)
47 /// tells them apart, because the page says something different for each.
48 pub text: Option<String>,
49 pub too_large: bool,
50}
51
52impl FileView {
53 /// Whether the file exists and is simply not displayable as text.
54 pub fn is_binary(&self) -> bool {
55 self.text.is_none() && !self.too_large
56 }
57}
58
59/// What is at a path in a repository.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum Browsed {
62 /// The repository has no commits. Distinct from an empty directory: there is
63 /// nothing to point a revision at, so the page offers push instructions rather than
64 /// an empty listing.
65 Empty,
66 Directory {
67 rev: RefName,
68 path: RepoPath,
69 /// Ordered directories-first, then case-insensitively by name.
70 entries: Vec<crate::domain::TreeEntry>,
71 },
72 File {
73 rev: RefName,
74 path: RepoPath,
75 file: FileView,
76 },
77}
78
79/// Resolves a path in a repository into whatever is there.
80///
81/// `rev` of `None` means the default branch, which is what a bare repository URL asks
82/// for.
83///
84/// `Ok(None)` means the repository is invisible, absent, or has nothing at that path —
85/// all rendered identically as 404, for the reason
86/// [`view_repo`](super::repo::view_repo) gives.
87#[allow(clippy::too_many_arguments)]
88pub async fn browse_repo(
89 handle: &OrgName,
90 name: &RepoName,
91 rev: Option<&RefName>,
92 path: &RepoPath,
93 actor: &Actor,
94 orgs: &impl OrgRepository,
95 memberships: &impl MembershipRepository,
96 repos: &impl RepoRepository,
97 queries: &impl GitQuery,
98) -> Result<Option<Browsed>> {
99 if view_repo(handle, name, actor, orgs, memberships, repos)
100 .await?
101 .is_none()
102 {
103 return Ok(None);
104 }
105
106 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
107 return Ok(Some(Browsed::Empty));
108 };
109
110 // A directory first, because that is the common case and the cheaper question.
111 if let Some(mut entries) = queries.list_tree(handle, name, &rev, path).await? {
112 entries.sort_by_key(crate::domain::TreeEntry::ordering_key);
113
114 return Ok(Some(Browsed::Directory {
115 rev,
116 path: path.clone(),
117 entries,
118 }));
119 }
120
121 let Some(blob) = queries
122 .read_blob(handle, name, &rev, path, MAX_BLOB_BYTES)
123 .await?
124 else {
125 return Ok(None);
126 };
127
128 Ok(Some(Browsed::File {
129 rev,
130 path: path.clone(),
131 file: view_of(blob),
132 }))
133}
134
135/// The commit log for a revision, newest first.
136///
137/// `Ok(None)` on the same terms as [`browse_repo`]. An empty repository logs nothing
138/// rather than failing.
139#[allow(clippy::too_many_arguments)]
140pub async fn repo_log(
141 handle: &OrgName,
142 name: &RepoName,
143 rev: Option<&RefName>,
144 actor: &Actor,
145 orgs: &impl OrgRepository,
146 memberships: &impl MembershipRepository,
147 repos: &impl RepoRepository,
148 queries: &impl GitQuery,
149) -> Result<Option<Vec<CommitSummary>>> {
150 if view_repo(handle, name, actor, orgs, memberships, repos)
151 .await?
152 .is_none()
153 {
154 return Ok(None);
155 }
156
157 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
158 return Ok(Some(Vec::new()));
159 };
160
161 Ok(Some(queries.log(handle, name, &rev, LOG_LIMIT).await?))
162}
163
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
164/// The branches and tags a repository has, ready for a switcher.
165///
166/// Two lists rather than one tagged list, because that is how they are shown: a
167/// visitor picking a revision is picking from branches *or* from tags, and the same
168/// name can legitimately appear in both.
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170pub struct RefList {
171 pub branches: Vec<RefName>,
172 pub tags: Vec<RefName>,
173}
174
175impl RefList {
176 pub fn is_empty(&self) -> bool {
177 self.branches.is_empty() && self.tags.is_empty()
178 }
179
180 /// Whether a revision names one of these refs.
181 ///
182 /// What a switcher uses to decide whether the current revision is a ref it can
183 /// highlight or an object id it has to show as itself.
184 pub fn contains(&self, rev: &RefName) -> bool {
185 self.branches
186 .iter()
187 .chain(&self.tags)
188 .any(|name| name == rev)
189 }
190}
191
192/// Every branch and tag, for the revision switcher.
193///
194/// `Ok(None)` on the same terms as [`browse_repo`]: invisible and absent are one answer.
195///
196/// **This costs one extra `git` process (~14ms) on top of whatever the page already
197/// spends**, so it is called by the pages that show a switcher and by nothing else. See
198/// the port's note on [`list_refs`](super::port::GitQuery::list_refs).
199///
200/// Ordering is decided here rather than in an adapter: branches then tags, each
201/// case-insensitively by name, with ties broken by the name itself so the order is
202/// total. The default branch is not floated to the top — it is usually first
203/// alphabetically anyway, and a list that reorders itself is harder to scan than one
204/// that does not.
205pub async fn list_refs(
206 handle: &OrgName,
207 name: &RepoName,
208 actor: &Actor,
209 orgs: &impl OrgRepository,
210 memberships: &impl MembershipRepository,
211 repos: &impl RepoRepository,
212 queries: &impl GitQuery,
213) -> Result<Option<RefList>> {
214 if view_repo(handle, name, actor, orgs, memberships, repos)
215 .await?
216 .is_none()
217 {
218 return Ok(None);
219 }
220
dd5b600feat: the facts a repository page states about itself19h
221 Ok(Some(organise_refs(queries.list_refs(handle, name).await?)))
222}
223
224/// Splits a raw ref list into branches and tags, each in the order a switcher shows.
225///
226/// Separate from [`list_refs`] because [`repo_summary`](super::summary::repo_summary)
227/// asks the port itself — it wants the counts *and* the switcher's list from one `git`
228/// process — and two places deciding the order is two places to change it.
229pub fn organise_refs(refs: Vec<crate::domain::GitRef>) -> RefList {
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
230 let mut list = RefList::default();
231
dd5b600feat: the facts a repository page states about itself19h
232 for git_ref in refs {
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
233 match git_ref.kind {
234 RefKind::Branch => list.branches.push(git_ref.name),
235 RefKind::Tag => list.tags.push(git_ref.name),
236 }
237 }
238
239 for names in [&mut list.branches, &mut list.tags] {
240 names.sort_by(|left, right| {
241 left.as_str()
242 .to_lowercase()
243 .cmp(&right.as_str().to_lowercase())
244 .then_with(|| left.as_str().cmp(right.as_str()))
245 });
246 }
247
dd5b600feat: the facts a repository page states about itself19h
248 list
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
249}
250
7ba8b8efeat: the branches and tags a page shows, and what it says when there are none19h
251/// A page of refs, and whether the repository has any history at all.
252///
253/// The second field is the distinction the branches and tags pages need but a list
254/// alone cannot make: "no tags yet" and "nothing pushed yet" are different sentences,
255/// and only the second one wants the push snippet beneath it.
256///
257/// It costs **one extra `git` process**, and only when the list came back empty — which
258/// is exactly when the page has nothing else to spend.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct RefPage<Row> {
261 pub rows: Vec<Row>,
262 /// No commits have been pushed: `HEAD` names a branch that does not exist.
263 pub repo_is_empty: bool,
264}
265
266/// Every branch, in the order the branches page shows them.
267///
268/// `Ok(None)` on the same terms as [`browse_repo`]: invisible and absent are one answer.
269///
270/// **One `git` process** — see [`branches`](super::port::GitQuery::branches) — plus one
271/// more only when there are no branches, to tell an empty repository from a repository
272/// whose refs are all tags.
273///
274/// Deliberately **no ahead/behind counts**: a count against the default branch is a
275/// `rev-list` per branch, so a repository with twenty branches would fork twenty extra
276/// processes to decorate one page. That is exactly the cost
277/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) bounds, and it
278/// stays out until something keeps git alive between questions.
279pub async fn list_branches(
280 handle: &OrgName,
281 name: &RepoName,
282 actor: &Actor,
283 orgs: &impl OrgRepository,
284 memberships: &impl MembershipRepository,
285 repos: &impl RepoRepository,
286 queries: &impl GitQuery,
287) -> Result<Option<RefPage<BranchRow>>> {
288 if view_repo(handle, name, actor, orgs, memberships, repos)
289 .await?
290 .is_none()
291 {
292 return Ok(None);
293 }
294
295 let rows = pin_default(queries.branches(handle, name).await?);
296
297 Ok(Some(RefPage {
298 repo_is_empty: rows.is_empty() && !has_commits(handle, name, queries).await?,
299 rows,
300 }))
301}
302
303/// Every tag, newest first. Authorized and costed exactly as [`list_branches`] is.
304pub async fn list_tags(
305 handle: &OrgName,
306 name: &RepoName,
307 actor: &Actor,
308 orgs: &impl OrgRepository,
309 memberships: &impl MembershipRepository,
310 repos: &impl RepoRepository,
311 queries: &impl GitQuery,
312) -> Result<Option<RefPage<TagRow>>> {
313 if view_repo(handle, name, actor, orgs, memberships, repos)
314 .await?
315 .is_none()
316 {
317 return Ok(None);
318 }
319
320 let rows = queries.tags(handle, name).await?;
321
322 Ok(Some(RefPage {
323 repo_is_empty: rows.is_empty() && !has_commits(handle, name, queries).await?,
324 rows,
325 }))
326}
327
328/// Whether anything has been pushed. One `git` process, asked only on an empty page.
329async fn has_commits(handle: &OrgName, name: &RepoName, queries: &impl GitQuery) -> Result<bool> {
330 Ok(queries.default_branch(handle, name).await?.is_some())
331}
332
333/// Moves the default branch to the front, leaving everything else in git's date order.
334///
335/// A display decision rather than the adapter's: the default branch is the one a
336/// visitor came for, and it is not reliably the most recently pushed — a repository
337/// whose work happens on feature branches would bury `main` halfway down a page
338/// otherwise. Everything below it stays newest-first, which is the ordering that
339/// answers "what is being worked on".
340///
341/// A stable partition rather than a sort, so the date order it was given survives.
342fn pin_default(rows: Vec<BranchRow>) -> Vec<BranchRow> {
343 let (default, rest): (Vec<_>, Vec<_>) = rows.into_iter().partition(|row| row.is_default);
344
345 default.into_iter().chain(rest).collect()
346}
347
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
348/// A file as it is served rather than rendered.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub enum RawFile {
351 Ready {
352 /// The file's own name, for the download it becomes.
353 name: String,
354 content: Vec<u8>,
355 },
356 /// Bigger than [`MAX_RAW_BYTES`]. Reported rather than served, because the port
357 /// reads bytes into memory; the size is carried so the refusal can say why.
358 TooLarge { size: u64 },
359}
360
361/// Reads a file for serving verbatim.
362///
363/// Authorized exactly as [`browse_repo`] is, through [`view_repo`], so a repository
364/// invisible on its page is invisible here too — a raw URL is not a side door.
365///
366/// `Ok(None)` for a repository that is invisible or absent, a revision that is not
367/// there, a path that is not there, and a path that is a directory. All of them are one
368/// answer for the reason [`view_repo`] gives, and a directory is included because there
369/// is no such thing as raw bytes for one.
370#[allow(clippy::too_many_arguments)]
371pub async fn read_raw_file(
372 handle: &OrgName,
373 name: &RepoName,
374 rev: Option<&RefName>,
375 path: &RepoPath,
376 actor: &Actor,
377 orgs: &impl OrgRepository,
378 memberships: &impl MembershipRepository,
379 repos: &impl RepoRepository,
380 queries: &impl GitQuery,
381) -> Result<Option<RawFile>> {
382 if view_repo(handle, name, actor, orgs, memberships, repos)
383 .await?
384 .is_none()
385 {
386 return Ok(None);
387 }
388
389 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
390 return Ok(None);
391 };
392
393 // Straight to the blob: unlike a browse, there is no directory case to serve, so
394 // asking `list_tree` first would spend a whole process learning that a path is not
395 // something this endpoint can answer for. `read_blob` already says `None` for a
396 // tree.
397 let Some(blob) = queries
398 .read_blob(handle, name, &rev, path, MAX_RAW_BYTES)
399 .await?
400 else {
401 return Ok(None);
402 };
403
404 let Some(content) = blob.content else {
405 return Ok(Some(RawFile::TooLarge { size: blob.size }));
406 };
407
408 Ok(Some(RawFile::Ready {
409 // A path that resolved to a blob has a last component by construction: the root
410 // is a tree, and `read_blob` refuses it.
411 name: path.file_name().unwrap_or_default().to_owned(),
412 content,
413 }))
414}
415
dce0bf3feat: browse a repository's files and history8d
416/// Settles which revision is being asked about.
417///
418/// `None` out means the repository has no commits at all — not that the revision was
419/// wrong, which surfaces later as nothing being found at the path.
420async fn resolve_revision(
421 handle: &OrgName,
422 name: &RepoName,
423 rev: Option<&RefName>,
424 queries: &impl GitQuery,
425) -> Result<Option<RefName>> {
426 match rev {
427 Some(rev) => Ok(Some(rev.clone())),
428 None => Ok(queries.default_branch(handle, name).await?),
429 }
430}
431
432/// Decides what can be done with a blob's bytes.
433///
434/// The port carries bytes and a size; turning those into "text", "binary" or "too big"
435/// is a display decision, so it happens here rather than in the adapter.
436fn view_of(blob: Blob) -> FileView {
437 let too_large = blob.content.is_none();
438 let text = blob.content.and_then(|bytes| String::from_utf8(bytes).ok());
439
440 FileView {
441 id: blob.id,
442 size: blob.size,
443 text,
444 too_large,
445 }
446}
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
447
448#[cfg(test)]
449mod tests {
ef23868feat: rebuild the profile page on flat navigation7d
450 use std::time::SystemTime;
451
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
452 use super::*;
453 use crate::{
454 domain::{
455 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
456 },
457 infrastructure::{
458 git::InMemoryGitQuery,
459 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
460 },
461 };
462
463 struct Fixture {
464 orgs: InMemoryOrgRepo,
465 memberships: InMemoryMembershipRepo,
466 repos: InMemoryRepoRepo,
467 handle: OrgName,
468 owner: Actor,
469 stranger: Actor,
470 }
471
472 /// One organisation with an owner, and a `steid` repository of the given visibility.
473 async fn fixture(visibility: Visibility) -> Fixture {
474 let orgs = InMemoryOrgRepo::new();
475 let memberships = InMemoryMembershipRepo::new();
476 let repos = InMemoryRepoRepo::new();
477
478 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
479 orgs.save(&org).await.expect("save org");
480
481 let owner = UserId::generate();
482 memberships
483 .save(&Membership::new(
484 MembershipId::generate(),
485 org.id.clone(),
486 owner.clone(),
487 crate::domain::Role::Owner,
488 ))
489 .await
490 .expect("save membership");
491
492 repos
493 .save(
494 &Repository::new(
495 RepoId::generate(),
496 org.id.clone(),
497 "steid",
498 None,
499 visibility,
ef23868feat: rebuild the profile page on flat navigation7d
500 SystemTime::now(),
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
501 )
502 .expect("valid repository"),
503 )
504 .await
505 .expect("save repo");
506
507 Fixture {
508 orgs,
509 memberships,
510 repos,
511 handle: org.name,
512 owner: Actor::User(owner),
513 stranger: Actor::Anonymous,
514 }
515 }
516
517 fn repo_name() -> RepoName {
518 RepoName::new("steid").expect("valid repository name")
519 }
520
521 fn rev(value: &str) -> RefName {
522 RefName::new(value).expect("valid revision")
523 }
524
525 fn path(value: &str) -> RepoPath {
526 RepoPath::new(value).expect("valid path")
527 }
528
529 impl Fixture {
530 async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
531 list_refs(
532 &self.handle,
533 &repo_name(),
534 actor,
535 &self.orgs,
536 &self.memberships,
537 &self.repos,
538 queries,
539 )
540 .await
541 }
542
543 async fn raw(
544 &self,
545 actor: &Actor,
546 path: &RepoPath,
547 queries: &InMemoryGitQuery,
548 ) -> Result<Option<RawFile>> {
549 read_raw_file(
550 &self.handle,
551 &repo_name(),
552 Some(&rev("main")),
553 path,
554 actor,
555 &self.orgs,
556 &self.memberships,
557 &self.repos,
558 queries,
559 )
560 .await
561 }
562 }
563
7ba8b8efeat: the branches and tags a page shows, and what it says when there are none19h
564 // --- list_branches and list_tags ------------------------------------------------
565
566 fn at(offset: u64) -> SystemTime {
567 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + offset)
568 }
569
570 #[tokio::test]
571 async fn the_default_branch_is_pinned_above_the_rest() {
572 let f = fixture(Visibility::Public).await;
573 // Seeded in git's order — newest tip first — with the default branch stalest,
574 // which is the case pinning exists for.
575 let queries = InMemoryGitQuery::new()
576 .with_branch_row("feature/login", false, at(200))
577 .with_branch_row("spike", false, at(100))
578 .with_branch_row("main", true, at(0));
579
580 let page = list_branches(
581 &f.handle,
582 &repo_name(),
583 &f.owner,
584 &f.orgs,
585 &f.memberships,
586 &f.repos,
587 &queries,
588 )
589 .await
590 .expect("should read")
591 .expect("visible");
592
593 assert_eq!(
594 page.rows
595 .iter()
596 .map(|row| row.name.as_str())
597 .collect::<Vec<_>>(),
598 vec!["main", "feature/login", "spike"]
599 );
600 // Pinning must not disturb the date order of everything below it.
601 assert!(!page.repo_is_empty);
602 }
603
604 #[tokio::test]
605 async fn a_repository_with_no_branches_says_it_is_empty() {
606 // `InMemoryGitQuery::empty()` has no default branch, which is what "nothing has
607 // been pushed" looks like — and what makes the page show the push snippet.
608 let f = fixture(Visibility::Public).await;
609
610 let page = list_branches(
611 &f.handle,
612 &repo_name(),
613 &f.owner,
614 &f.orgs,
615 &f.memberships,
616 &f.repos,
617 &InMemoryGitQuery::empty(),
618 )
619 .await
620 .expect("should read")
621 .expect("visible");
622
623 assert!(page.rows.is_empty());
624 assert!(page.repo_is_empty);
625 }
626
627 #[tokio::test]
628 async fn a_repository_with_commits_but_no_tags_is_not_empty() {
629 // The distinction the page needs: "no tags yet" rather than "nothing pushed".
630 let f = fixture(Visibility::Public).await;
631
632 let page = list_tags(
633 &f.handle,
634 &repo_name(),
635 &f.owner,
636 &f.orgs,
637 &f.memberships,
638 &f.repos,
639 &InMemoryGitQuery::new(),
640 )
641 .await
642 .expect("should read")
643 .expect("visible");
644
645 assert!(page.rows.is_empty());
646 assert!(!page.repo_is_empty);
647 }
648
649 #[tokio::test]
650 async fn tags_keep_the_order_git_sorted_them_into() {
651 let f = fixture(Visibility::Public).await;
652 let queries = InMemoryGitQuery::new()
653 .with_tag_row("v2.0", true, at(200))
654 .with_tag_row("v0.9", false, at(100));
655
656 let page = list_tags(
657 &f.handle,
658 &repo_name(),
659 &f.owner,
660 &f.orgs,
661 &f.memberships,
662 &f.repos,
663 &queries,
664 )
665 .await
666 .expect("should read")
667 .expect("visible");
668
669 assert_eq!(
670 page.rows
671 .iter()
672 .map(|row| row.name.as_str())
673 .collect::<Vec<_>>(),
674 vec!["v2.0", "v0.9"]
675 );
676 assert_eq!(page.rows[0].message.as_deref(), Some("release v2.0"));
677 assert_eq!(page.rows[1].message, None);
678 }
679
680 #[tokio::test]
681 async fn a_private_repositorys_branches_and_tags_are_invisible_to_a_stranger() {
682 // A branch name is content, the same way the switcher's list is.
683 let f = fixture(Visibility::Private).await;
684 let queries = InMemoryGitQuery::new()
685 .with_branch_row("secret-work", true, at(0))
686 .with_tag_row("unreleased", true, at(0));
687
688 assert!(
689 list_branches(
690 &f.handle,
691 &repo_name(),
692 &f.stranger,
693 &f.orgs,
694 &f.memberships,
695 &f.repos,
696 &queries,
697 )
698 .await
699 .expect("should read")
700 .is_none()
701 );
702 assert!(
703 list_tags(
704 &f.handle,
705 &repo_name(),
706 &f.stranger,
707 &f.orgs,
708 &f.memberships,
709 &f.repos,
710 &queries,
711 )
712 .await
713 .expect("should read")
714 .is_none()
715 );
716 }
717
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
718 // --- list_refs ----------------------------------------------------------------
719
720 #[tokio::test]
721 async fn branches_and_tags_come_back_separated_and_ordered() {
722 let f = fixture(Visibility::Public).await;
723 let queries = InMemoryGitQuery::new()
724 .with_branch("main")
725 .with_branch("Feature")
726 .with_tag("v2.0")
727 .with_tag("v1.0");
728
729 let refs = f
730 .refs(&f.owner, &queries)
731 .await
732 .expect("should read")
733 .expect("visible");
734
735 // Case-insensitively, so `Feature` sorts next to `feature` rather than before
736 // every lowercase name.
737 assert_eq!(
738 refs.branches
739 .iter()
740 .map(RefName::as_str)
741 .collect::<Vec<_>>(),
742 vec!["Feature", "main"]
743 );
744 assert_eq!(
745 refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
746 vec!["v1.0", "v2.0"]
747 );
748 }
749
750 #[tokio::test]
751 async fn an_empty_repository_has_no_refs_to_switch_between() {
752 let f = fixture(Visibility::Public).await;
753
754 let refs = f
755 .refs(&f.owner, &InMemoryGitQuery::empty())
756 .await
757 .expect("should read")
758 .expect("visible");
759
760 assert!(refs.is_empty());
761 }
762
763 #[tokio::test]
764 async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
765 // The ref list names branches, which are content. Same answer as the page.
766 let f = fixture(Visibility::Private).await;
767 let queries = InMemoryGitQuery::new().with_branch("secret-work");
768
769 assert!(
770 f.refs(&f.stranger, &queries)
771 .await
772 .expect("should read")
773 .is_none()
774 );
775 assert!(
776 f.refs(&f.owner, &queries)
777 .await
778 .expect("should read")
779 .is_some()
780 );
781 }
782
783 #[test]
784 fn a_ref_list_knows_the_revision_it_is_showing() {
785 let refs = RefList {
786 branches: vec![RefName::from_trusted("main")],
787 tags: vec![RefName::from_trusted("v1.0")],
788 };
789
790 assert!(refs.contains(&rev("main")));
791 assert!(refs.contains(&rev("v1.0")));
792 // An object id is not a ref, which is what a switcher needs to know before it
793 // tries to highlight one.
794 assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
795 }
796
797 // --- read_raw_file ------------------------------------------------------------
798
799 #[tokio::test]
800 async fn a_text_file_comes_back_with_its_own_name() {
801 let f = fixture(Visibility::Public).await;
802 let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
803
804 let raw = f
805 .raw(&f.owner, &path("src/main.rs"), &queries)
806 .await
807 .expect("should read")
808 .expect("found");
809
810 assert_eq!(
811 raw,
812 RawFile::Ready {
813 name: "main.rs".to_owned(),
814 content: b"fn main() {}\n".to_vec(),
815 }
816 );
817 }
818
819 #[tokio::test]
820 async fn a_binary_file_comes_back_byte_for_byte() {
821 // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
822 let f = fixture(Visibility::Public).await;
823 let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
824 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
825
826 let raw = f
827 .raw(&f.owner, &path("logo.png"), &queries)
828 .await
829 .expect("should read")
830 .expect("found");
831
832 match raw {
833 RawFile::Ready { content, .. } => assert_eq!(content, bytes),
834 other => panic!("expected the bytes, got {other:?}"),
835 }
836 }
837
838 #[tokio::test]
839 async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
840 let f = fixture(Visibility::Public).await;
841 let size = MAX_RAW_BYTES as usize + 1;
842 let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
843
844 let raw = f
845 .raw(&f.owner, &path("huge.bin"), &queries)
846 .await
847 .expect("should read")
848 .expect("found");
849
850 assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
851 }
852
853 #[tokio::test]
854 async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
855 // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
856 // these bytes in a browser, so the reason for the page's limit does not apply.
857 let f = fixture(Visibility::Public).await;
858 let size = MAX_BLOB_BYTES as usize + 1;
859 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
860
861 let raw = f
862 .raw(&f.owner, &path("big.txt"), &queries)
863 .await
864 .expect("should read")
865 .expect("found");
866
867 match raw {
868 RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
869 other => panic!("expected the bytes, got {other:?}"),
870 }
871 }
872
873 #[tokio::test]
874 async fn a_directory_has_no_raw_bytes() {
875 // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
876 // not a file, and there is nothing to serve.
877 let f = fixture(Visibility::Public).await;
878 let queries = InMemoryGitQuery::new()
879 .with_tree("main", "src", Vec::new())
880 .with_blob("main", "src/main.rs", b"fn main() {}\n");
881
882 assert!(
883 f.raw(&f.owner, &path("src"), &queries)
884 .await
885 .expect("should read")
886 .is_none()
887 );
888 }
889
890 #[tokio::test]
891 async fn a_path_that_is_not_there_is_not_found() {
892 let f = fixture(Visibility::Public).await;
893 let queries = InMemoryGitQuery::new();
894
895 assert!(
896 f.raw(&f.owner, &path("nope.txt"), &queries)
897 .await
898 .expect("should read")
899 .is_none()
900 );
901 }
902
903 #[tokio::test]
904 async fn an_empty_repository_serves_nothing_raw() {
905 let f = fixture(Visibility::Public).await;
906
907 assert!(
908 f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
909 .await
910 .expect("should read")
911 .is_none()
912 );
913 }
914
915 #[tokio::test]
916 async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
917 // The point of the endpoint's authorization: a raw URL is not a way around the
918 // page's answer.
919 let f = fixture(Visibility::Private).await;
920 let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
921
922 assert!(
923 f.raw(&f.stranger, &path("secret.txt"), &queries)
924 .await
925 .expect("should read")
926 .is_none()
927 );
928 assert!(
929 f.raw(&f.owner, &path("secret.txt"), &queries)
930 .await
931 .expect("should read")
932 .is_some()
933 );
934 }
935}