steid

@jamesgill /

steid/src/application/summary.rs
18.4 KBCode·Blame·Raw
dd5b600feat: the facts a repository page states about itself17h
1//! The facts a repository's landing page states about itself.
2//!
3//! A read that composes several git calls, given a home in the application layer
4//! rather than left in a component. `architecture.md` allows a straightforward read to
5//! query directly; this is not one — it is five questions whose answers are gathered
6//! concurrently and whose licence detection is a rule rather than a lookup, and a page
7//! is the wrong place for either.
8//!
9//! Authorization is not re-implemented: like everything in
10//! [`browse`](super::browse), it goes through
11//! [`view_repo`](super::repo::view_repo), so an invisible repository has no facts.
12
13use crate::domain::{
14 Actor, CommitSummary, EntryKind, OrgName, RefName, RepoName, RepoPath, TagSummary, TreeEntry,
15 repository::{MembershipRepository, OrgRepository, RepoRepository},
16};
17
18use super::{
19 browse::{MAX_BLOB_BYTES, RefList, organise_refs},
20 error::Result,
21 port::GitQuery,
22 repo::view_repo,
23};
24
25/// What the About sidebar states about a repository.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RepoFacts {
28 /// The most recent commit on the revision being shown, for the commit bar.
29 pub latest_commit: Option<CommitSummary>,
30 pub commits: u64,
31 /// Carried whole rather than as two counts, because the page that wants the counts
32 /// also draws the revision switcher — and asking git for the ref list twice on one
33 /// page would be a whole process spent on a number it already had.
34 pub refs: RefList,
35 pub latest_tag: Option<TagSummary>,
36 pub licence: Option<Licence>,
37}
38
39/// The licence a repository appears to be under.
40///
41/// `name` is `None` when a licence file is there but its text names nothing
42/// recognisable. That is deliberately not a guess: the page says "Licence" and links to
43/// the file, which is honest, where naming the wrong licence is a claim about someone's
44/// legal terms.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Licence {
47 /// The file it was found in, so the page can link to it.
48 pub path: RepoPath,
49 pub name: Option<&'static str>,
50}
51
52/// Gathers everything the repository page states about itself.
53///
54/// Takes the revision and the root listing the page has already read, rather than
55/// reading them again: the caller has just browsed the root to render the file list,
56/// and a second `list_tree` would be a `git` process spent on bytes already in hand.
57///
58/// The five git calls run concurrently, so the page waits for the slowest rather than
59/// for the sum. They are still five processes — see this step's note in
60/// `plans/progress.md` for the count and
61/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) for the bill
62/// that is eventually coming due.
63///
64/// `Ok(None)` on the same terms as [`browse_repo`](super::browse::browse_repo):
65/// invisible and absent are one answer.
66#[allow(clippy::too_many_arguments)]
67pub async fn repo_summary(
68 handle: &OrgName,
69 name: &RepoName,
70 rev: &RefName,
71 root: &[TreeEntry],
72 actor: &Actor,
73 orgs: &impl OrgRepository,
74 memberships: &impl MembershipRepository,
75 repos: &impl RepoRepository,
76 queries: &impl GitQuery,
77) -> Result<Option<RepoFacts>> {
78 if view_repo(handle, name, actor, orgs, memberships, repos)
79 .await?
80 .is_none()
81 {
82 return Ok(None);
83 }
84
85 let licence_path = licence_file(root);
86
87 let (log, commits, refs, latest_tag, licence_text) = tokio::try_join!(
88 // One commit: the bar above the file list shows the latest, and nothing here
89 // needs the rest of the history.
90 queries.log(handle, name, rev, 1),
91 queries.count_commits(handle, name, rev),
92 queries.list_refs(handle, name),
93 queries.latest_tag(handle, name),
94 async {
95 let Some(path) = &licence_path else {
96 return Ok(None);
97 };
98
99 // Read whole rather than capped at the sniffing window: the port has no
100 // partial read, and a cap below a licence's real size comes back as "too
101 // large" — which would leave Apache-2.0, at 11 KB, permanently unnamed.
102 Ok(queries
103 .read_blob(handle, name, rev, path, MAX_BLOB_BYTES)
104 .await?
105 .and_then(|blob| blob.content)
106 .and_then(|bytes| String::from_utf8(bytes).ok()))
107 },
108 )?;
109
110 Ok(Some(RepoFacts {
111 latest_commit: log.into_iter().next(),
112 commits,
113 refs: organise_refs(refs),
114 latest_tag,
115 licence: licence_path.map(|path| Licence {
116 path,
117 name: licence_text.as_deref().and_then(sniff_licence),
118 }),
119 }))
120}
121
122/// The filenames a licence lives under, lowercased.
123///
124/// Filename-only, and a short list: this runs over a listing the page already has, so
125/// it costs nothing, whereas guessing at `LICENSE-MIT` and friends would multiply the
126/// blob reads. A repository with several licence files gets whichever comes first in
127/// this order.
128const LICENCE_FILES: [&str; 4] = ["license", "license.md", "license.txt", "copying"];
129
130/// How much of a licence to look at.
131///
132/// A kilobyte rather than the couple of hundred bytes a title needs, because BSD is
133/// identified by *which clauses it has* and the third one sits about 900 bytes in. Past
134/// that there is nothing left that distinguishes one licence from another.
135const SNIFF_BYTES: usize = 1024;
136
137/// The licence file in a root listing, if there is one.
138///
139/// Case-insensitive, because all of `LICENSE`, `License` and `license` appear in the
140/// wild and mean the same thing.
141fn licence_file(entries: &[TreeEntry]) -> Option<RepoPath> {
142 entries
143 .iter()
144 .filter(|entry| entry.kind == EntryKind::Blob)
145 .filter_map(|entry| {
146 let lowered = entry.name.to_ascii_lowercase();
147 let rank = LICENCE_FILES.iter().position(|name| *name == lowered)?;
148
149 Some((rank, entry))
150 })
151 .min_by_key(|(rank, _)| *rank)
152 // The entry came out of a root listing, so its name is a single component and
153 // the only way this fails is a name `RepoPath` refuses — in which case there is
154 // no link to offer and no licence to name.
155 .and_then(|(_, entry)| RepoPath::new(&entry.name).ok())
156}
157
158/// Which licence a text appears to be, by the phrases only that licence uses.
159///
160/// Deliberately not a classifier. Every arm here is a phrase from the licence's own
161/// preamble, matched case-insensitively, and anything that matches nothing is `None` —
162/// see [`Licence`] for why a guess is worse than silence.
163///
164/// Order matters where one licence's opening is a prefix of another's: ISC and the
165/// Unlicense both read like MIT for the first few words, and Affero opens with the GPL's
166/// own title.
167fn sniff_licence(text: &str) -> Option<&'static str> {
168 let head: String = text
169 .chars()
170 .take(SNIFF_BYTES)
171 .collect::<String>()
172 .to_ascii_lowercase();
173 let has = |needle: &str| head.contains(needle);
174
175 if has("gnu affero general public license") {
176 return Some("AGPL-3.0");
177 }
178
179 if has("gnu general public license") && has("version 3") {
180 return Some("GPL-3.0");
181 }
182
183 if has("apache license") && has("version 2.0") {
184 return Some("Apache-2.0");
185 }
186
187 if has("mozilla public license") && has("version 2.0") {
188 return Some("MPL-2.0");
189 }
190
191 if has("this is free and unencumbered software released into the public domain") {
192 return Some("Unlicense");
193 }
194
195 // ISC's grant is MIT's with "and/or distribute" in place of MIT's much longer list
196 // of verbs, so it has to be ruled out before MIT is considered.
197 if has("permission to use, copy, modify, and/or distribute") {
198 return Some("ISC");
199 }
200
201 if has("mit license") || has("permission is hereby granted, free of charge") {
202 return Some("MIT");
203 }
204
205 // The BSD family shares one opening and differs only in its clauses.
206 if has("redistribution and use in source and binary forms") {
207 return Some(if has("neither the name") {
208 "BSD-3-Clause"
209 } else {
210 "BSD-2-Clause"
211 });
212 }
213
214 None
215}
216
217#[cfg(test)]
218mod tests {
219 use std::time::{Duration, SystemTime, UNIX_EPOCH};
220
221 use super::*;
222 use crate::{
223 domain::{
224 Membership, MembershipId, ObjectId, OrgId, Organization, RepoId, Repository, UserId,
225 Visibility,
226 },
227 infrastructure::{
228 git::InMemoryGitQuery,
229 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
230 },
231 };
232
233 fn entry(name: &str, kind: EntryKind) -> TreeEntry {
234 TreeEntry {
235 name: name.to_owned(),
236 kind,
237 id: ObjectId::from_trusted("0".repeat(40)),
238 size: Some(0),
239 }
240 }
241
242 // --- finding the file ---------------------------------------------------------
243
244 #[test]
245 fn the_usual_licence_filenames_are_all_found() {
246 for name in [
247 "LICENSE",
248 "license",
249 "License.md",
250 "LICENSE.txt",
251 "COPYING",
252 "copying",
253 ] {
254 let entries = [entry(name, EntryKind::Blob)];
255
256 assert_eq!(
257 licence_file(&entries).as_ref().map(RepoPath::as_str),
258 Some(name),
259 "{name} should be a licence"
260 );
261 }
262 }
263
264 #[test]
265 fn files_that_merely_mention_a_licence_are_not_the_licence() {
266 for name in ["LICENSE-MIT", "licensing.md", "NOTICE", "README.md"] {
267 let entries = [entry(name, EntryKind::Blob)];
268
269 assert!(
270 licence_file(&entries).is_none(),
271 "{name} should not be a licence"
272 );
273 }
274 }
275
276 #[test]
277 fn a_directory_called_license_is_not_a_licence() {
278 // Reading it would ask git for a blob at a tree's path and get nothing.
279 let entries = [entry("LICENSE", EntryKind::Tree)];
280
281 assert!(licence_file(&entries).is_none());
282 }
283
284 #[test]
285 fn the_plainest_spelling_wins_when_there_are_several() {
286 let entries = [
287 entry("COPYING", EntryKind::Blob),
288 entry("LICENSE", EntryKind::Blob),
289 ];
290
291 assert_eq!(
292 licence_file(&entries).as_ref().map(RepoPath::as_str),
293 Some("LICENSE")
294 );
295 }
296
297 // --- naming it ----------------------------------------------------------------
298
299 #[test]
300 fn the_common_licences_are_recognised_from_their_own_words() {
301 let cases = [
302 (
303 " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n",
304 "AGPL-3.0",
305 ),
306 (
307 " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n",
308 "GPL-3.0",
309 ),
310 (
311 " Apache License\n Version 2.0, January 2004\n",
312 "Apache-2.0",
313 ),
314 (
315 "Mozilla Public License Version 2.0\n==================================\n",
316 "MPL-2.0",
317 ),
318 (
319 "MIT License\n\nCopyright (c) 2026 Ada\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n",
320 "MIT",
321 ),
322 (
323 "Copyright (c) 2026 Ada\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n",
324 "ISC",
325 ),
326 (
327 "This is free and unencumbered software released into the public domain.\n",
328 "Unlicense",
329 ),
330 ];
331
332 for (text, expected) in cases {
333 assert_eq!(sniff_licence(text), Some(expected), "for {text:?}");
334 }
335 }
336
337 #[test]
338 fn the_bsd_licences_are_told_apart_by_their_third_clause() {
339 let shared = "Copyright (c) 2026 Ada. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice.\n\n2. Redistributions in binary form must reproduce the above copyright notice.\n";
340
341 assert_eq!(sniff_licence(shared), Some("BSD-2-Clause"));
342 assert_eq!(
343 sniff_licence(&format!(
344 "{shared}\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse products.\n"
345 )),
346 Some("BSD-3-Clause")
347 );
348 }
349
350 #[test]
351 fn a_licence_it_does_not_know_is_left_unnamed_rather_than_guessed() {
352 // The page still links to the file — see `Licence`.
353 assert_eq!(sniff_licence("All rights reserved. Ask me nicely.\n"), None);
354 assert_eq!(sniff_licence(""), None);
355 }
356
357 #[test]
358 fn only_the_head_of_a_file_is_read() {
359 // A licence that names another one deep in its text — Apache's appendix, a
360 // vendored notice — must not change what the file itself is.
361 let text = format!(
362 "MIT License\n{}\nGNU AFFERO GENERAL PUBLIC LICENSE\n",
363 "x".repeat(SNIFF_BYTES)
364 );
365
366 assert_eq!(sniff_licence(&text), Some("MIT"));
367 }
368
369 // --- the whole read -----------------------------------------------------------
370
371 struct Fixture {
372 orgs: InMemoryOrgRepo,
373 memberships: InMemoryMembershipRepo,
374 repos: InMemoryRepoRepo,
375 handle: OrgName,
376 owner: Actor,
377 stranger: Actor,
378 }
379
380 /// One organisation with an owner, and a `steid` repository of the given visibility.
381 async fn fixture(visibility: Visibility) -> Fixture {
382 let orgs = InMemoryOrgRepo::new();
383 let memberships = InMemoryMembershipRepo::new();
384 let repos = InMemoryRepoRepo::new();
385
386 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
387 orgs.save(&org).await.expect("save org");
388
389 let owner = UserId::generate();
390 memberships
391 .save(&Membership::new(
392 MembershipId::generate(),
393 org.id.clone(),
394 owner.clone(),
395 crate::domain::Role::Owner,
396 ))
397 .await
398 .expect("save membership");
399
400 repos
401 .save(
402 &Repository::new(
403 RepoId::generate(),
404 org.id.clone(),
405 "steid",
406 None,
407 visibility,
408 SystemTime::now(),
409 )
410 .expect("valid repository"),
411 )
412 .await
413 .expect("save repo");
414
415 Fixture {
416 orgs,
417 memberships,
418 repos,
419 handle: org.name,
420 owner: Actor::User(owner),
421 stranger: Actor::Anonymous,
422 }
423 }
424
425 impl Fixture {
426 async fn facts(
427 &self,
428 actor: &Actor,
429 root: &[TreeEntry],
430 queries: &InMemoryGitQuery,
431 ) -> Result<Option<RepoFacts>> {
432 repo_summary(
433 &self.handle,
434 &RepoName::new("steid").expect("valid repository name"),
435 &RefName::from_trusted("main"),
436 root,
437 actor,
438 &self.orgs,
439 &self.memberships,
440 &self.repos,
441 queries,
442 )
443 .await
444 }
445 }
446
447 fn commit(summary: &str) -> CommitSummary {
448 CommitSummary {
449 id: ObjectId::from_trusted("a".repeat(40)),
450 summary: summary.to_owned(),
451 author_name: "Ada Lovelace".to_owned(),
452 committed_at: UNIX_EPOCH + Duration::from_secs(1_700_000_000),
453 }
454 }
455
456 #[tokio::test]
457 async fn the_facts_come_back_composed_from_every_source() {
458 let f = fixture(Visibility::Public).await;
459 let root = [
460 entry("src", EntryKind::Tree),
461 entry("LICENSE", EntryKind::Blob),
462 ];
463 let queries = InMemoryGitQuery::new()
464 .with_log(vec![commit("feat: a global top bar")])
465 .with_commit_count(128)
466 .with_branch("main")
467 .with_branch("next")
468 .with_tag("v0.1.0")
469 .with_latest_tag("v0.1.0", UNIX_EPOCH + Duration::from_secs(1_700_000_000))
470 .with_blob(
471 "main",
472 "LICENSE",
473 b" GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n".to_vec(),
474 );
475
476 let facts = f
477 .facts(&f.owner, &root, &queries)
478 .await
479 .expect("should read")
480 .expect("visible");
481
482 assert_eq!(
483 facts.latest_commit.map(|commit| commit.summary),
484 Some("feat: a global top bar".to_owned())
485 );
486 assert_eq!(facts.commits, 128);
487 assert_eq!(facts.refs.branches.len(), 2);
488 assert_eq!(facts.refs.tags.len(), 1);
489 assert_eq!(
490 facts.latest_tag.map(|tag| tag.name.to_string()),
491 Some("v0.1.0".to_owned())
492 );
493 assert_eq!(
494 facts.licence,
495 Some(Licence {
496 path: RepoPath::from_trusted("LICENSE"),
497 name: Some("AGPL-3.0"),
498 })
499 );
500 }
501
502 #[tokio::test]
503 async fn a_repository_with_no_licence_file_states_no_licence() {
504 // The sparse state: nothing is read, and nothing is claimed.
505 let f = fixture(Visibility::Public).await;
506 let root = [entry("src", EntryKind::Tree)];
507
508 let facts = f
509 .facts(&f.owner, &root, &InMemoryGitQuery::new())
510 .await
511 .expect("should read")
512 .expect("visible");
513
514 assert_eq!(facts.licence, None);
515 assert_eq!(facts.latest_commit, None);
516 assert_eq!(facts.commits, 0);
517 }
518
519 #[tokio::test]
520 async fn a_private_repositorys_facts_are_invisible_to_a_stranger() {
521 // A commit count and a branch list are content. Same answer as the page.
522 let f = fixture(Visibility::Private).await;
523 let queries = InMemoryGitQuery::new().with_branch("secret-work");
524
525 assert!(
526 f.facts(&f.stranger, &[], &queries)
527 .await
528 .expect("should read")
529 .is_none()
530 );
531 assert!(
532 f.facts(&f.owner, &[], &queries)
533 .await
534 .expect("should read")
535 .is_some()
536 );
537 }
538}