steid

@jamesgill /

21.4 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::{
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
8 Actor, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
dce0bf3feat: browse a repository's files and history8d
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
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
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
dce0bf3feat: browse a repository's files and history8d
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
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
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
dd5b600feat: the facts a repository page states about itself21h
220 Ok(Some(organise_refs(queries.list_refs(handle, name).await?)))
221}
222
223/// Splits a raw ref list into branches and tags, each in the order a switcher shows.
224///
225/// Separate from [`list_refs`] because [`repo_summary`](super::summary::repo_summary)
226/// asks the port itself — it wants the counts *and* the switcher's list from one `git`
227/// process — and two places deciding the order is two places to change it.
228pub fn organise_refs(refs: Vec<crate::domain::GitRef>) -> RefList {
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
229 let mut list = RefList::default();
230
dd5b600feat: the facts a repository page states about itself21h
231 for git_ref in refs {
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
232 match git_ref.kind {
233 RefKind::Branch => list.branches.push(git_ref.name),
234 RefKind::Tag => list.tags.push(git_ref.name),
235 }
236 }
237
238 for names in [&mut list.branches, &mut list.tags] {
239 names.sort_by(|left, right| {
240 left.as_str()
241 .to_lowercase()
242 .cmp(&right.as_str().to_lowercase())
243 .then_with(|| left.as_str().cmp(right.as_str()))
244 });
245 }
246
dd5b600feat: the facts a repository page states about itself21h
247 list
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
248}
249
250/// A file as it is served rather than rendered.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum RawFile {
253 Ready {
254 /// The file's own name, for the download it becomes.
255 name: String,
256 content: Vec<u8>,
257 },
258 /// Bigger than [`MAX_RAW_BYTES`]. Reported rather than served, because the port
259 /// reads bytes into memory; the size is carried so the refusal can say why.
260 TooLarge { size: u64 },
261}
262
263/// Reads a file for serving verbatim.
264///
265/// Authorized exactly as [`browse_repo`] is, through [`view_repo`], so a repository
266/// invisible on its page is invisible here too — a raw URL is not a side door.
267///
268/// `Ok(None)` for a repository that is invisible or absent, a revision that is not
269/// there, a path that is not there, and a path that is a directory. All of them are one
270/// answer for the reason [`view_repo`] gives, and a directory is included because there
271/// is no such thing as raw bytes for one.
272#[allow(clippy::too_many_arguments)]
273pub async fn read_raw_file(
274 handle: &OrgName,
275 name: &RepoName,
276 rev: Option<&RefName>,
277 path: &RepoPath,
278 actor: &Actor,
279 orgs: &impl OrgRepository,
280 memberships: &impl MembershipRepository,
281 repos: &impl RepoRepository,
282 queries: &impl GitQuery,
283) -> Result<Option<RawFile>> {
284 if view_repo(handle, name, actor, orgs, memberships, repos)
285 .await?
286 .is_none()
287 {
288 return Ok(None);
289 }
290
291 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
292 return Ok(None);
293 };
294
295 // Straight to the blob: unlike a browse, there is no directory case to serve, so
296 // asking `list_tree` first would spend a whole process learning that a path is not
297 // something this endpoint can answer for. `read_blob` already says `None` for a
298 // tree.
299 let Some(blob) = queries
300 .read_blob(handle, name, &rev, path, MAX_RAW_BYTES)
301 .await?
302 else {
303 return Ok(None);
304 };
305
306 let Some(content) = blob.content else {
307 return Ok(Some(RawFile::TooLarge { size: blob.size }));
308 };
309
310 Ok(Some(RawFile::Ready {
311 // A path that resolved to a blob has a last component by construction: the root
312 // is a tree, and `read_blob` refuses it.
313 name: path.file_name().unwrap_or_default().to_owned(),
314 content,
315 }))
316}
317
dce0bf3feat: browse a repository's files and history8d
318/// Settles which revision is being asked about.
319///
320/// `None` out means the repository has no commits at all — not that the revision was
321/// wrong, which surfaces later as nothing being found at the path.
322async fn resolve_revision(
323 handle: &OrgName,
324 name: &RepoName,
325 rev: Option<&RefName>,
326 queries: &impl GitQuery,
327) -> Result<Option<RefName>> {
328 match rev {
329 Some(rev) => Ok(Some(rev.clone())),
330 None => Ok(queries.default_branch(handle, name).await?),
331 }
332}
333
334/// Decides what can be done with a blob's bytes.
335///
336/// The port carries bytes and a size; turning those into "text", "binary" or "too big"
337/// is a display decision, so it happens here rather than in the adapter.
338fn view_of(blob: Blob) -> FileView {
339 let too_large = blob.content.is_none();
340 let text = blob.content.and_then(|bytes| String::from_utf8(bytes).ok());
341
342 FileView {
343 id: blob.id,
344 size: blob.size,
345 text,
346 too_large,
347 }
348}
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
349
350#[cfg(test)]
351mod tests {
ef23868feat: rebuild the profile page on flat navigation7d
352 use std::time::SystemTime;
353
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
354 use super::*;
355 use crate::{
356 domain::{
357 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
358 },
359 infrastructure::{
360 git::InMemoryGitQuery,
361 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
362 },
363 };
364
365 struct Fixture {
366 orgs: InMemoryOrgRepo,
367 memberships: InMemoryMembershipRepo,
368 repos: InMemoryRepoRepo,
369 handle: OrgName,
370 owner: Actor,
371 stranger: Actor,
372 }
373
374 /// One organisation with an owner, and a `steid` repository of the given visibility.
375 async fn fixture(visibility: Visibility) -> Fixture {
376 let orgs = InMemoryOrgRepo::new();
377 let memberships = InMemoryMembershipRepo::new();
378 let repos = InMemoryRepoRepo::new();
379
380 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
381 orgs.save(&org).await.expect("save org");
382
383 let owner = UserId::generate();
384 memberships
385 .save(&Membership::new(
386 MembershipId::generate(),
387 org.id.clone(),
388 owner.clone(),
389 crate::domain::Role::Owner,
390 ))
391 .await
392 .expect("save membership");
393
394 repos
395 .save(
396 &Repository::new(
397 RepoId::generate(),
398 org.id.clone(),
399 "steid",
400 None,
401 visibility,
ef23868feat: rebuild the profile page on flat navigation7d
402 SystemTime::now(),
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
403 )
404 .expect("valid repository"),
405 )
406 .await
407 .expect("save repo");
408
409 Fixture {
410 orgs,
411 memberships,
412 repos,
413 handle: org.name,
414 owner: Actor::User(owner),
415 stranger: Actor::Anonymous,
416 }
417 }
418
419 fn repo_name() -> RepoName {
420 RepoName::new("steid").expect("valid repository name")
421 }
422
423 fn rev(value: &str) -> RefName {
424 RefName::new(value).expect("valid revision")
425 }
426
427 fn path(value: &str) -> RepoPath {
428 RepoPath::new(value).expect("valid path")
429 }
430
431 impl Fixture {
432 async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
433 list_refs(
434 &self.handle,
435 &repo_name(),
436 actor,
437 &self.orgs,
438 &self.memberships,
439 &self.repos,
440 queries,
441 )
442 .await
443 }
444
445 async fn raw(
446 &self,
447 actor: &Actor,
448 path: &RepoPath,
449 queries: &InMemoryGitQuery,
450 ) -> Result<Option<RawFile>> {
451 read_raw_file(
452 &self.handle,
453 &repo_name(),
454 Some(&rev("main")),
455 path,
456 actor,
457 &self.orgs,
458 &self.memberships,
459 &self.repos,
460 queries,
461 )
462 .await
463 }
464 }
465
466 // --- list_refs ----------------------------------------------------------------
467
468 #[tokio::test]
469 async fn branches_and_tags_come_back_separated_and_ordered() {
470 let f = fixture(Visibility::Public).await;
471 let queries = InMemoryGitQuery::new()
472 .with_branch("main")
473 .with_branch("Feature")
474 .with_tag("v2.0")
475 .with_tag("v1.0");
476
477 let refs = f
478 .refs(&f.owner, &queries)
479 .await
480 .expect("should read")
481 .expect("visible");
482
483 // Case-insensitively, so `Feature` sorts next to `feature` rather than before
484 // every lowercase name.
485 assert_eq!(
486 refs.branches
487 .iter()
488 .map(RefName::as_str)
489 .collect::<Vec<_>>(),
490 vec!["Feature", "main"]
491 );
492 assert_eq!(
493 refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
494 vec!["v1.0", "v2.0"]
495 );
496 }
497
498 #[tokio::test]
499 async fn an_empty_repository_has_no_refs_to_switch_between() {
500 let f = fixture(Visibility::Public).await;
501
502 let refs = f
503 .refs(&f.owner, &InMemoryGitQuery::empty())
504 .await
505 .expect("should read")
506 .expect("visible");
507
508 assert!(refs.is_empty());
509 }
510
511 #[tokio::test]
512 async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
513 // The ref list names branches, which are content. Same answer as the page.
514 let f = fixture(Visibility::Private).await;
515 let queries = InMemoryGitQuery::new().with_branch("secret-work");
516
517 assert!(
518 f.refs(&f.stranger, &queries)
519 .await
520 .expect("should read")
521 .is_none()
522 );
523 assert!(
524 f.refs(&f.owner, &queries)
525 .await
526 .expect("should read")
527 .is_some()
528 );
529 }
530
531 #[test]
532 fn a_ref_list_knows_the_revision_it_is_showing() {
533 let refs = RefList {
534 branches: vec![RefName::from_trusted("main")],
535 tags: vec![RefName::from_trusted("v1.0")],
536 };
537
538 assert!(refs.contains(&rev("main")));
539 assert!(refs.contains(&rev("v1.0")));
540 // An object id is not a ref, which is what a switcher needs to know before it
541 // tries to highlight one.
542 assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
543 }
544
545 // --- read_raw_file ------------------------------------------------------------
546
547 #[tokio::test]
548 async fn a_text_file_comes_back_with_its_own_name() {
549 let f = fixture(Visibility::Public).await;
550 let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
551
552 let raw = f
553 .raw(&f.owner, &path("src/main.rs"), &queries)
554 .await
555 .expect("should read")
556 .expect("found");
557
558 assert_eq!(
559 raw,
560 RawFile::Ready {
561 name: "main.rs".to_owned(),
562 content: b"fn main() {}\n".to_vec(),
563 }
564 );
565 }
566
567 #[tokio::test]
568 async fn a_binary_file_comes_back_byte_for_byte() {
569 // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
570 let f = fixture(Visibility::Public).await;
571 let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
572 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
573
574 let raw = f
575 .raw(&f.owner, &path("logo.png"), &queries)
576 .await
577 .expect("should read")
578 .expect("found");
579
580 match raw {
581 RawFile::Ready { content, .. } => assert_eq!(content, bytes),
582 other => panic!("expected the bytes, got {other:?}"),
583 }
584 }
585
586 #[tokio::test]
587 async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
588 let f = fixture(Visibility::Public).await;
589 let size = MAX_RAW_BYTES as usize + 1;
590 let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
591
592 let raw = f
593 .raw(&f.owner, &path("huge.bin"), &queries)
594 .await
595 .expect("should read")
596 .expect("found");
597
598 assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
599 }
600
601 #[tokio::test]
602 async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
603 // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
604 // these bytes in a browser, so the reason for the page's limit does not apply.
605 let f = fixture(Visibility::Public).await;
606 let size = MAX_BLOB_BYTES as usize + 1;
607 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
608
609 let raw = f
610 .raw(&f.owner, &path("big.txt"), &queries)
611 .await
612 .expect("should read")
613 .expect("found");
614
615 match raw {
616 RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
617 other => panic!("expected the bytes, got {other:?}"),
618 }
619 }
620
621 #[tokio::test]
622 async fn a_directory_has_no_raw_bytes() {
623 // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
624 // not a file, and there is nothing to serve.
625 let f = fixture(Visibility::Public).await;
626 let queries = InMemoryGitQuery::new()
627 .with_tree("main", "src", Vec::new())
628 .with_blob("main", "src/main.rs", b"fn main() {}\n");
629
630 assert!(
631 f.raw(&f.owner, &path("src"), &queries)
632 .await
633 .expect("should read")
634 .is_none()
635 );
636 }
637
638 #[tokio::test]
639 async fn a_path_that_is_not_there_is_not_found() {
640 let f = fixture(Visibility::Public).await;
641 let queries = InMemoryGitQuery::new();
642
643 assert!(
644 f.raw(&f.owner, &path("nope.txt"), &queries)
645 .await
646 .expect("should read")
647 .is_none()
648 );
649 }
650
651 #[tokio::test]
652 async fn an_empty_repository_serves_nothing_raw() {
653 let f = fixture(Visibility::Public).await;
654
655 assert!(
656 f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
657 .await
658 .expect("should read")
659 .is_none()
660 );
661 }
662
663 #[tokio::test]
664 async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
665 // The point of the endpoint's authorization: a raw URL is not a way around the
666 // page's answer.
667 let f = fixture(Visibility::Private).await;
668 let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
669
670 assert!(
671 f.raw(&f.stranger, &path("secret.txt"), &queries)
672 .await
673 .expect("should read")
674 .is_none()
675 );
676 assert!(
677 f.raw(&f.owner, &path("secret.txt"), &queries)
678 .await
679 .expect("should read")
680 .is_some()
681 );
682 }
683}