| 1 | |
| 2 | |
| 3 | |
| 4 | use std::process::Stdio; |
| 5 | |
| 6 | use forge_git::browse; |
| 7 | use forge_git::history::{self, ArchiveFormat}; |
| 8 | use futures_util::TryStreamExt; |
| 9 | use http::header::{ |
| 10 | CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue, X_CONTENT_TYPE_OPTIONS, |
| 11 | }; |
| 12 | use http_body::Frame; |
| 13 | use http_body_util::StreamBody; |
| 14 | use tokio::io::AsyncReadExt; |
| 15 | use tokio::process::Child; |
| 16 | use tokio_util::io::ReaderStream; |
| 17 | use topcoat::Result; |
| 18 | use topcoat::context::{Cx, app_context}; |
| 19 | use topcoat::router::error::not_found; |
| 20 | use topcoat::router::response::Response; |
| 21 | use topcoat::router::{Body, path_param, route}; |
| 22 | |
| 23 | use crate::pages::commits::blocking; |
| 24 | use crate::pages::repo_ctx::load_repo; |
| 25 | use crate::state::AppState; |
| 26 | |
| 27 | path_param!(owner); |
| 28 | path_param!(repo); |
| 29 | path_param!(*rest); |
| 30 | |
| 31 | |
| 32 | fn split_format(name: &str) -> Option<(&str, ArchiveFormat)> { |
| 33 | let (reference, format) = name |
| 34 | .strip_suffix(".tar.gz") |
| 35 | .map(|r| (r, ArchiveFormat::TarGz)) |
| 36 | .or_else(|| name.strip_suffix(".zip").map(|r| (r, ArchiveFormat::Zip)))?; |
| 37 | (!reference.is_empty()).then_some((reference, format)) |
| 38 | } |
| 39 | |
| 40 | |
| 41 | |
| 42 | fn archive_prefix(repo: &str, reference: &str, commit: &str) -> String { |
| 43 | let sanitized: String = reference |
| 44 | .chars() |
| 45 | .map(|c| { |
| 46 | if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { |
| 47 | c |
| 48 | } else { |
| 49 | '-' |
| 50 | } |
| 51 | }) |
| 52 | .collect(); |
| 53 | let is_hex = reference.bytes().all(|b| b.is_ascii_hexdigit()); |
| 54 | let tail = if sanitized.is_empty() || is_hex { |
| 55 | commit.get(..12).unwrap_or(commit).to_owned() |
| 56 | } else { |
| 57 | sanitized |
| 58 | }; |
| 59 | format!("{repo}-{tail}") |
| 60 | } |
| 61 | |
| 62 | #[route(GET "/{owner}/{repo}/archive/{*rest}")] |
| 63 | async fn archive(cx: &Cx) -> Result<Response> { |
| 64 | let owner = path_param::<Owner>(cx); |
| 65 | let name = path_param::<Repo>(cx); |
| 66 | let rest = path_param::<Rest>(cx).collect::<Vec<_>>().join("/"); |
| 67 | let Some((reference, format)) = split_format(&rest) else { |
| 68 | return Err(not_found().into()); |
| 69 | }; |
| 70 | |
| 71 | let ctx = load_repo(cx, owner, name).await?; |
| 72 | let state = app_context::<AppState>(cx); |
| 73 | |
| 74 | let repo_dir = ctx.path.clone(); |
| 75 | let rev = reference.to_owned(); |
| 76 | let Some(commit) = blocking(move || browse::resolve_commit(&repo_dir, &rev)).await? else { |
| 77 | return Err(not_found().into()); |
| 78 | }; |
| 79 | |
| 80 | let prefix = archive_prefix(&ctx.repo.name, reference, &commit); |
| 81 | let (content_type, extension) = match format { |
| 82 | ArchiveFormat::TarGz => ("application/gzip", "tar.gz"), |
| 83 | ArchiveFormat::Zip => ("application/zip", "zip"), |
| 84 | }; |
| 85 | let mut command = |
| 86 | history::archive_command(&state.git, &ctx.path, &commit, format, &prefix)?.into_tokio(); |
| 87 | command |
| 88 | .stdin(Stdio::null()) |
| 89 | .stdout(Stdio::piped()) |
| 90 | .stderr(Stdio::piped()); |
| 91 | let mut child = command.spawn()?; |
| 92 | let Some(stdout) = child.stdout.take() else { |
| 93 | return Err(std::io::Error::other("git archive stdout missing").into()); |
| 94 | }; |
| 95 | tokio::spawn(reap(child)); |
| 96 | |
| 97 | let chunks = ReaderStream::new(stdout).map_ok(Frame::data); |
| 98 | let mut res = Response::new(Body::new(StreamBody::new(chunks))); |
| 99 | let headers = res.headers_mut(); |
| 100 | headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); |
| 101 | headers.insert( |
| 102 | CONTENT_DISPOSITION, |
| 103 | HeaderValue::from_str(&format!("attachment; filename=\"{prefix}.{extension}\""))?, |
| 104 | ); |
| 105 | headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 106 | headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); |
| 107 | Ok(res) |
| 108 | } |
| 109 | |
| 110 | |
| 111 | async fn reap(mut child: Child) { |
| 112 | let mut stderr = Vec::new(); |
| 113 | if let Some(mut pipe) = child.stderr.take() { |
| 114 | let _ = pipe.read_to_end(&mut stderr).await; |
| 115 | } |
| 116 | match child.wait().await { |
| 117 | Ok(status) if status.success() => {} |
| 118 | Ok(status) => tracing::warn!( |
| 119 | %status, |
| 120 | stderr = %String::from_utf8_lossy(&stderr).trim(), |
| 121 | "git archive exited with failure" |
| 122 | ), |
| 123 | Err(e) => tracing::warn!(error = %e, "failed to wait for git archive"), |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | #[cfg(test)] |
| 128 | mod tests { |
| 129 | use forge_core::Visibility; |
| 130 | |
| 131 | use super::{archive_prefix, split_format}; |
| 132 | use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get}; |
| 133 | use forge_git::history::ArchiveFormat; |
| 134 | |
| 135 | #[test] |
| 136 | fn splits_ref_and_format() { |
| 137 | assert!(matches!( |
| 138 | split_format("feature/x.tar.gz"), |
| 139 | Some(("feature/x", ArchiveFormat::TarGz)) |
| 140 | )); |
| 141 | assert!(matches!( |
| 142 | split_format("v1.zip"), |
| 143 | Some(("v1", ArchiveFormat::Zip)) |
| 144 | )); |
| 145 | assert!(split_format("main.rar").is_none()); |
| 146 | assert!(split_format(".zip").is_none()); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn prefix_is_sanitized() { |
| 151 | assert_eq!( |
| 152 | archive_prefix("demo", "feature/x y", "f".repeat(40).as_str()), |
| 153 | "demo-feature-x-y" |
| 154 | ); |
| 155 | let oid = "0123456789abcdef0123456789abcdef01234567"; |
| 156 | assert_eq!(archive_prefix("demo", oid, oid), "demo-0123456789ab"); |
| 157 | } |
| 158 | |
| 159 | #[tokio::test] |
| 160 | async fn tar_gz_and_zip_responses() { |
| 161 | let fx = Fixture::new(Visibility::Public).await; |
| 162 | |
| 163 | let (status, headers, body) = |
| 164 | get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await; |
| 165 | assert_eq!(status, 200); |
| 166 | assert_eq!(headers["content-type"], "application/gzip"); |
| 167 | assert_eq!( |
| 168 | headers["content-disposition"], |
| 169 | "attachment; filename=\"demo-main.tar.gz\"" |
| 170 | ); |
| 171 | assert_eq!(headers["x-content-type-options"], "nosniff"); |
| 172 | assert_eq!(&body[..2], &[0x1f, 0x8b]); |
| 173 | |
| 174 | let (status, headers, body) = |
| 175 | get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.zip")).await; |
| 176 | assert_eq!(status, 200); |
| 177 | assert_eq!(headers["content-type"], "application/zip"); |
| 178 | assert_eq!(&body[..2], b"PK"); |
| 179 | |
| 180 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/nope.zip")).await; |
| 181 | assert_eq!(status, 404); |
| 182 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.rar")).await; |
| 183 | assert_eq!(status, 404); |
| 184 | } |
| 185 | |
| 186 | #[tokio::test] |
| 187 | async fn private_repo_is_404_for_anonymous() { |
| 188 | let fx = Fixture::new(Visibility::Private).await; |
| 189 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/archive/main.tar.gz")).await; |
| 190 | assert_eq!(status, 404); |
| 191 | } |
| 192 | } |