Skip to content
85 linesCodeBlameRaw
1//! Archive links: what lets a download of a branch, tag or commit as a
2//! tarball or zip (the site's `/<owner>/<repo>/archive/<commit>.tar.gz`)
3//! work with no session or access token, for the REST API's
4//! `GET /repos/{owner}/{repo}/tarball/{ref}` and `/zipball/{ref}`, which
5//! answer with a redirect to it as GitHub's do to codeload.
6//!
7//! The token is the commit and when it ends, sealed (AES-256-GCM) under
8//! the service's `REPOS_KEY` with the repository's id as associated data:
9//! it opens only for that repository, whatever it is called by then, and
10//! only for that commit, for [`ARCHIVE_LINK_SECONDS`]. Made only for
11//! someone who can read the repository; whoever holds it may download that
12//! one commit until it ends.
13
14use base64::Engine;
15use base64::engine::general_purpose::URL_SAFE_NO_PAD;
16use g1t_contracts::repos::ARCHIVE_LINK_SECONDS;
17use g1t_secrets::Sealer;
18use serde::{Deserialize, Serialize};
19
20#[derive(Serialize, Deserialize)]
21struct Sealed {
22 /// The commit.
23 c: String,
24 /// When it ends, in milliseconds since the epoch.
25 e: u64,
26}
27
28fn bound_to(repo_id: &str) -> String {
29 format!("archive link:{repo_id}")
30}
31
32/// A token for `commit` of the repository `repo_id`, and when it ends.
33pub fn seal(sealer: &Sealer, repo_id: &str, commit: &str, now_ms: u64) -> (String, u64) {
34 let expires = now_ms + ARCHIVE_LINK_SECONDS * 1000;
35 let plain = serde_json::to_vec(&Sealed { c: commit.to_owned(), e: expires }).expect("serializable");
36 (URL_SAFE_NO_PAD.encode(sealer.seal_bytes(&plain, &bound_to(repo_id))), expires)
37}
38
39/// The commit a token lets anyone download from the repository `repo_id`,
40/// while it has not ended.
41pub fn open(sealer: &Sealer, repo_id: &str, token: &str, now_ms: u64) -> Option<String> {
42 if token.len() > 1024 {
43 return None;
44 }
45 let sealed = URL_SAFE_NO_PAD.decode(token.trim()).ok()?;
46 let plain = sealer.open_bytes(&sealed, &bound_to(repo_id))?;
47 let Sealed { c, e } = serde_json::from_slice(&plain).ok()?;
48 (e > now_ms && is_commit(&c)).then_some(c)
49}
50
51fn is_commit(value: &str) -> bool {
52 matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 fn sealer() -> Sealer {
60 Sealer::new(&"ab".repeat(32)).unwrap()
61 }
62
63 const COMMIT: &str = "4807077b296e6edbf410d55e72749d3e1170c291";
64
65 #[test]
66 fn a_token_opens_for_its_repository_and_commit_until_it_ends() {
67 let sealer = sealer();
68 let (token, expires) = seal(&sealer, "repo_1", COMMIT, 1_000);
69 assert_eq!(expires, 1_000 + 600_000);
70 // Safe in a query string as it is.
71 assert!(token.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'));
72 assert_eq!(open(&sealer, "repo_1", &token, 2_000).as_deref(), Some(COMMIT));
73 // Not for another repository, nor once it has ended.
74 assert_eq!(open(&sealer, "repo_2", &token, 2_000), None);
75 assert_eq!(open(&sealer, "repo_1", &token, expires), None);
76 // Nor under another key, nor changed.
77 let other = Sealer::new(&"cd".repeat(32)).unwrap();
78 assert_eq!(open(&other, "repo_1", &token, 2_000), None);
79 let mut changed = token.clone().into_bytes();
80 let last = changed.len() - 2;
81 changed[last] = if changed[last] == b'A' { b'B' } else { b'A' };
82 assert_eq!(open(&sealer, "repo_1", &String::from_utf8(changed).unwrap(), 2_000), None);
83 assert_eq!(open(&sealer, "repo_1", "not a token", 2_000), None);
84 }
85}