| 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 | |
| 14 | use base64::Engine; |
| 15 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 16 | use g1t_contracts::repos::ARCHIVE_LINK_SECONDS; |
| 17 | use g1t_secrets::Sealer; |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | #[derive(Serialize, Deserialize)] |
| 21 | struct Sealed { |
| 22 | /// The commit. |
| 23 | c: String, |
| 24 | /// When it ends, in milliseconds since the epoch. |
| 25 | e: u64, |
| 26 | } |
| 27 | |
| 28 | fn 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. |
| 33 | pub 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. |
| 41 | pub 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 | |
| 51 | fn is_commit(value: &str) -> bool { |
| 52 | matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) |
| 53 | } |
| 54 | |
| 55 | #[cfg(test)] |
| 56 | mod 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 | } |