Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +2 −0
| @@ -5707,6 +5707,7 @@ dependencies = [ | |||
| 5707 | "comrak", | 5707 | "comrak", |
| 5708 | "config", | 5708 | "config", |
| 5709 | "flate2", | 5709 | "flate2", |
| 5710 | "futures-util", | ||
| 5710 | "git", | 5711 | "git", |
| 5711 | "git2", | 5712 | "git2", |
| 5712 | "grep", | 5713 | "grep", |
| @@ -5719,6 +5720,7 @@ dependencies = [ | |||
| 5719 | "rust-embed", | 5720 | "rust-embed", |
| 5720 | "serde", | 5721 | "serde", |
| 5721 | "serde_json", | 5722 | "serde_json", |
| 5723 | "sha2 0.10.9", | ||
| 5722 | "store", | 5724 | "store", |
| 5723 | "tempfile", | 5725 | "tempfile", |
| 5724 | "thiserror 2.0.19", | 5726 | "thiserror 2.0.19", |
Cargo.toml +2 −0
| @@ -157,6 +157,8 @@ ammonia = "4" | |||
| 157 | # Streaming the pack subprocess stdout into the HTTP response body; gunzip for | 157 | # Streaming the pack subprocess stdout into the HTTP response body; gunzip for |
| 158 | # gzip-encoded upload-pack requests. | 158 | # gzip-encoded upload-pack requests. |
| 159 | tokio-util = { version = "0.7", features = ["io"] } | 159 | tokio-util = { version = "0.7", features = ["io"] } |
| 160 | # Stream adapters for LFS: turn the request body stream into an AsyncRead. | ||
| 161 | futures-util = "0.3" | ||
| 160 | flate2 = "1" | 162 | flate2 = "1" |
| 161 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | 163 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. |
| 162 | grep = "0.3" | 164 | grep = "0.3" |
crates/ssh/src/command.rs +27 −0
| @@ -40,6 +40,33 @@ pub fn parse(command: &str) -> Option<GitCommand> { | |||
| 40 | }) | 40 | }) |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | /// A parsed `git-lfs-authenticate <path> <operation>` command: the git-lfs client | ||
| 44 | /// runs this over SSH to obtain an HTTP endpoint and bearer token for LFS transfer. | ||
| 45 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 46 | pub struct LfsAuth { | ||
| 47 | /// The repository path argument (still needs normalizing). | ||
| 48 | pub path: String, | ||
| 49 | /// The operation: `upload` or `download`. | ||
| 50 | pub operation: String, | ||
| 51 | } | ||
| 52 | |||
| 53 | /// Parse a `git-lfs-authenticate <path> <operation>` command line, or `None`. | ||
| 54 | #[must_use] | ||
| 55 | pub fn parse_lfs(command: &str) -> Option<LfsAuth> { | ||
| 56 | let words = shell_split(command)?; | ||
| 57 | let (name, rest) = words.split_first()?; | ||
| 58 | if name != "git-lfs-authenticate" || rest.len() != 2 { | ||
| 59 | return None; | ||
| 60 | } | ||
| 61 | if rest[1] != "upload" && rest[1] != "download" { | ||
| 62 | return None; | ||
| 63 | } | ||
| 64 | Some(LfsAuth { | ||
| 65 | path: rest[0].clone(), | ||
| 66 | operation: rest[1].clone(), | ||
| 67 | }) | ||
| 68 | } | ||
| 69 | |||
| 43 | /// Split a command line into words, honouring single and double quotes (git sends | 70 | /// Split a command line into words, honouring single and double quotes (git sends |
| 44 | /// the path single-quoted). Returns `None` on an unterminated quote. | 71 | /// the path single-quoted). Returns `None` on an unterminated quote. |
| 45 | fn shell_split(input: &str) -> Option<Vec<String>> { | 72 | fn shell_split(input: &str) -> Option<Vec<String>> { |
crates/ssh/src/server.rs +106 −0
| @@ -26,6 +26,8 @@ struct Shared { | |||
| 26 | config: Arc<Config>, | 26 | config: Arc<Config>, |
| 27 | /// The `--config` path to hand hooks via `FABRICA_CONFIG`. | 27 | /// The `--config` path to hand hooks via `FABRICA_CONFIG`. |
| 28 | config_path: Option<String>, | 28 | config_path: Option<String>, |
| 29 | /// The instance signing secret, for minting short-lived LFS bearer tokens. | ||
| 30 | secret: String, | ||
| 29 | } | 31 | } |
| 30 | 32 | ||
| 31 | /// The russh `Server` factory. | 33 | /// The russh `Server` factory. |
| @@ -188,6 +190,12 @@ impl GitHandler { | |||
| 188 | let Some(identity) = self.identity.clone() else { | 190 | let Some(identity) = self.identity.clone() else { |
| 189 | return Err("Authentication required.".to_string()); | 191 | return Err("Authentication required.".to_string()); |
| 190 | }; | 192 | }; |
| 193 | // git-lfs-authenticate: hand back an HTTP endpoint + bearer token, then exit. | ||
| 194 | if let Some(lfs) = command::parse_lfs(command) { | ||
| 195 | return self | ||
| 196 | .lfs_authenticate(channel, &identity, &lfs, session) | ||
| 197 | .await; | ||
| 198 | } | ||
| 191 | let parsed = command::parse(command).ok_or_else(|| self.no_shell_banner())?; | 199 | let parsed = command::parse(command).ok_or_else(|| self.no_shell_banner())?; |
| 192 | let (owner, repo_path) = resolve::normalize(&parsed.path) | 200 | let (owner, repo_path) = resolve::normalize(&parsed.path) |
| 193 | .ok_or_else(|| "Invalid repository path.".to_string())?; | 201 | .ok_or_else(|| "Invalid repository path.".to_string())?; |
| @@ -255,6 +263,102 @@ impl GitHandler { | |||
| 255 | Ok(()) | 263 | Ok(()) |
| 256 | } | 264 | } |
| 257 | 265 | ||
| 266 | /// Handle `git-lfs-authenticate`: authorize the repo for the operation, mint a | ||
| 267 | /// short-lived bearer token, and write the LFS endpoint JSON to stdout. The | ||
| 268 | /// actual object transfer then happens over HTTP against that endpoint. | ||
| 269 | async fn lfs_authenticate( | ||
| 270 | &mut self, | ||
| 271 | channel: ChannelId, | ||
| 272 | identity: &Identity, | ||
| 273 | lfs: &command::LfsAuth, | ||
| 274 | session: &mut Session, | ||
| 275 | ) -> Result<(), String> { | ||
| 276 | let (owner, repo_path) = | ||
| 277 | resolve::normalize(&lfs.path).ok_or_else(|| "Invalid repository path.".to_string())?; | ||
| 278 | let not_found = || "Repository not found.".to_string(); | ||
| 279 | let owner_user = self | ||
| 280 | .shared | ||
| 281 | .store | ||
| 282 | .user_by_username(&owner) | ||
| 283 | .await | ||
| 284 | .ok() | ||
| 285 | .flatten() | ||
| 286 | .ok_or_else(not_found)?; | ||
| 287 | let repo = self | ||
| 288 | .shared | ||
| 289 | .store | ||
| 290 | .repo_by_owner_path(&owner_user.id, &repo_path) | ||
| 291 | .await | ||
| 292 | .ok() | ||
| 293 | .flatten() | ||
| 294 | .ok_or_else(not_found)?; | ||
| 295 | let collaborator = self | ||
| 296 | .shared | ||
| 297 | .store | ||
| 298 | .collaborator_permission(&repo.id, &identity.user_id) | ||
| 299 | .await | ||
| 300 | .ok() | ||
| 301 | .flatten() | ||
| 302 | .and_then(|p| auth::Permission::parse(&p)); | ||
| 303 | let viewer = auth::Viewer { | ||
| 304 | id: identity.user_id.clone(), | ||
| 305 | is_admin: identity.is_admin, | ||
| 306 | }; | ||
| 307 | let access = auth::access( | ||
| 308 | Some(&viewer), | ||
| 309 | &repo, | ||
| 310 | collaborator, | ||
| 311 | self.shared.config.instance.allow_anonymous, | ||
| 312 | ); | ||
| 313 | let required = if lfs.operation == "upload" { | ||
| 314 | auth::Access::Write | ||
| 315 | } else { | ||
| 316 | auth::Access::Read | ||
| 317 | }; | ||
| 318 | if access < required { | ||
| 319 | return Err(if access >= auth::Access::Read { | ||
| 320 | "You do not have write access to this repository.".to_string() | ||
| 321 | } else { | ||
| 322 | not_found() | ||
| 323 | }); | ||
| 324 | } | ||
| 325 | |||
| 326 | // A one-hour bearer token the HTTP LFS endpoints accept (verified by | ||
| 327 | // signature + expiry, not the API-token revocation path). | ||
| 328 | let now_s = i64::try_from( | ||
| 329 | std::time::SystemTime::now() | ||
| 330 | .duration_since(std::time::UNIX_EPOCH) | ||
| 331 | .map_or(0, |d| d.as_secs()), | ||
| 332 | ) | ||
| 333 | .unwrap_or(0); | ||
| 334 | let claims = auth::Claims { | ||
| 335 | sub: identity.user_id.clone(), | ||
| 336 | jti: format!("lfs-{}", repo.id), | ||
| 337 | scopes: "lfs".to_string(), | ||
| 338 | iat: now_s, | ||
| 339 | exp: now_s + 3600, | ||
| 340 | }; | ||
| 341 | let token = auth::mint_jwt(&self.shared.secret, &claims) | ||
| 342 | .map_err(|_| "Could not issue an LFS token.".to_string())?; | ||
| 343 | let href = format!( | ||
| 344 | "{}/{}/{}.git/info/lfs", | ||
| 345 | self.shared.config.instance.url.trim_end_matches('/'), | ||
| 346 | owner, | ||
| 347 | repo_path | ||
| 348 | ); | ||
| 349 | // git-lfs reads this JSON from stdout. href/token contain only URL- and | ||
| 350 | // JWT-safe characters, so no escaping is needed. | ||
| 351 | let json = format!( | ||
| 352 | "{{\"href\":\"{href}\",\"header\":{{\"Authorization\":\"Bearer {token}\"}},\"expires_in\":3600}}\n" | ||
| 353 | ); | ||
| 354 | let handle = session.handle(); | ||
| 355 | let _ = handle.data(channel, json.into_bytes()).await; | ||
| 356 | let _ = handle.exit_status_request(channel, 0).await; | ||
| 357 | let _ = handle.eof(channel).await; | ||
| 358 | let _ = handle.close(channel).await; | ||
| 359 | Ok(()) | ||
| 360 | } | ||
| 361 | |||
| 258 | /// Resolve and authorize the repo for the requested service. | 362 | /// Resolve and authorize the repo for the requested service. |
| 259 | async fn resolve_repo( | 363 | async fn resolve_repo( |
| 260 | &self, | 364 | &self, |
| @@ -399,6 +503,7 @@ pub async fn serve( | |||
| 399 | config: Arc<Config>, | 503 | config: Arc<Config>, |
| 400 | store: Store, | 504 | store: Store, |
| 401 | config_path: Option<String>, | 505 | config_path: Option<String>, |
| 506 | secret: String, | ||
| 402 | ) -> Result<(), SshError> { | 507 | ) -> Result<(), SshError> { |
| 403 | if !config.ssh.enabled { | 508 | if !config.ssh.enabled { |
| 404 | tracing::info!("ssh disabled (ssh.enabled = false)"); | 509 | tracing::info!("ssh disabled (ssh.enabled = false)"); |
| @@ -420,6 +525,7 @@ pub async fn serve( | |||
| 420 | store, | 525 | store, |
| 421 | config: Arc::clone(&config), | 526 | config: Arc::clone(&config), |
| 422 | config_path, | 527 | config_path, |
| 528 | secret, | ||
| 423 | }); | 529 | }); |
| 424 | let addr = (config.ssh.address.clone(), config.ssh.port); | 530 | let addr = (config.ssh.address.clone(), config.ssh.port); |
| 425 | tracing::info!(address = %config.ssh.address, port = config.ssh.port, "ssh server listening"); | 531 | tracing::info!(address = %config.ssh.address, port = config.ssh.port, "ssh server listening"); |
crates/store/src/tests.rs +35 −0
| @@ -825,6 +825,41 @@ async fn ensure_group_path_rejects_excessive_nesting() { | |||
| 825 | } | 825 | } |
| 826 | 826 | ||
| 827 | #[tokio::test] | 827 | #[tokio::test] |
| 828 | async fn lfs_objects_track_per_repo() { | ||
| 829 | for fx in sqlite_fixtures().await { | ||
| 830 | let owner = fx | ||
| 831 | .store | ||
| 832 | .create_user(sample_user("lfs", "lfs@example.com")) | ||
| 833 | .await | ||
| 834 | .unwrap(); | ||
| 835 | let repo = fx | ||
| 836 | .store | ||
| 837 | .create_repo(NewRepo { | ||
| 838 | owner_id: owner.id, | ||
| 839 | group_id: None, | ||
| 840 | name: "r".to_string(), | ||
| 841 | path: "r".to_string(), | ||
| 842 | description: None, | ||
| 843 | visibility: model::Visibility::Private, | ||
| 844 | default_branch: "main".to_string(), | ||
| 845 | }) | ||
| 846 | .await | ||
| 847 | .unwrap(); | ||
| 848 | let oid = "a".repeat(64); | ||
| 849 | assert!(!fx.store.lfs_object_exists(&repo.id, &oid).await.unwrap()); | ||
| 850 | fx.store.lfs_object_add(&repo.id, &oid, 1234).await.unwrap(); | ||
| 851 | // A repeat add is a no-op (idempotent), not a conflict. | ||
| 852 | fx.store.lfs_object_add(&repo.id, &oid, 1234).await.unwrap(); | ||
| 853 | assert!(fx.store.lfs_object_exists(&repo.id, &oid).await.unwrap()); | ||
| 854 | assert_eq!( | ||
| 855 | fx.store.lfs_object_size(&repo.id, &oid).await.unwrap(), | ||
| 856 | Some(1234) | ||
| 857 | ); | ||
| 858 | assert_eq!(fx.store.lfs_repo_bytes(&repo.id).await.unwrap(), 1234); | ||
| 859 | } | ||
| 860 | } | ||
| 861 | |||
| 862 | #[tokio::test] | ||
| 828 | async fn keys_get_stable_per_user_ordinals() { | 863 | async fn keys_get_stable_per_user_ordinals() { |
| 829 | for fx in sqlite_fixtures().await { | 864 | for fx in sqlite_fixtures().await { |
| 830 | let user = fx | 865 | let user = fx |
crates/web/Cargo.toml +2 −0
| @@ -28,6 +28,8 @@ mime_guess = { workspace = true } | |||
| 28 | comrak = { workspace = true } | 28 | comrak = { workspace = true } |
| 29 | ammonia = { workspace = true } | 29 | ammonia = { workspace = true } |
| 30 | tokio-util = { workspace = true } | 30 | tokio-util = { workspace = true } |
| 31 | futures-util = { workspace = true } | ||
| 32 | sha2 = { workspace = true } | ||
| 31 | flate2 = { workspace = true } | 33 | flate2 = { workspace = true } |
| 32 | grep = { workspace = true } | 34 | grep = { workspace = true } |
| 33 | reqwest = { workspace = true } | 35 | reqwest = { workspace = true } |
crates/web/src/git_http.rs +53 −7
| @@ -50,7 +50,7 @@ pub async fn http_get( | |||
| 50 | _ => return StatusCode::FORBIDDEN.into_response(), | 50 | _ => return StatusCode::FORBIDDEN.into_response(), |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | let repo = match authorize(state, jar, headers, owner, repo_path).await { | 53 | let repo = match authorize(state, jar, headers, owner, repo_path, auth::Access::Read).await { |
| 54 | Ok(repo) => repo, | 54 | Ok(repo) => repo, |
| 55 | Err(response) => return response, | 55 | Err(response) => return response, |
| 56 | }; | 56 | }; |
| @@ -66,6 +66,17 @@ pub async fn pack_rpc( | |||
| 66 | Path((owner, rest)): Path<(String, String)>, | 66 | Path((owner, rest)): Path<(String, String)>, |
| 67 | body: Body, | 67 | body: Body, |
| 68 | ) -> Response { | 68 | ) -> Response { |
| 69 | // LFS batch/verify share the POST catch-all; route them before pack RPC. | ||
| 70 | if let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) { | ||
| 71 | let repo_path = repo_path.to_string(); | ||
| 72 | return match tail { | ||
| 73 | "objects/batch" => crate::lfs::batch(state, jar, headers, owner, repo_path, body).await, | ||
| 74 | t if t.starts_with("objects/") && t.ends_with("/verify") => { | ||
| 75 | crate::lfs::verify(state, jar, headers, owner, repo_path, body).await | ||
| 76 | } | ||
| 77 | _ => StatusCode::NOT_FOUND.into_response(), | ||
| 78 | }; | ||
| 79 | } | ||
| 69 | let Some((repo_path, tail)) = split_git(&rest) else { | 80 | let Some((repo_path, tail)) = split_git(&rest) else { |
| 70 | return StatusCode::NOT_FOUND.into_response(); | 81 | return StatusCode::NOT_FOUND.into_response(); |
| 71 | }; | 82 | }; |
| @@ -75,7 +86,16 @@ pub async fn pack_rpc( | |||
| 75 | _ => return StatusCode::NOT_FOUND.into_response(), | 86 | _ => return StatusCode::NOT_FOUND.into_response(), |
| 76 | } | 87 | } |
| 77 | 88 | ||
| 78 | let repo = match authorize(&state, &jar, &headers, &owner, repo_path).await { | 89 | let repo = match authorize( |
| 90 | &state, | ||
| 91 | &jar, | ||
| 92 | &headers, | ||
| 93 | &owner, | ||
| 94 | repo_path, | ||
| 95 | auth::Access::Read, | ||
| 96 | ) | ||
| 97 | .await | ||
| 98 | { | ||
| 79 | Ok(repo) => repo, | 99 | Ok(repo) => repo, |
| 80 | Err(response) => return response, | 100 | Err(response) => return response, |
| 81 | }; | 101 | }; |
| @@ -187,12 +207,13 @@ async fn upload_pack_rpc( | |||
| 187 | /// to send: `401` (anonymous, credentials needed) or `404` (missing, or an | 207 | /// to send: `401` (anonymous, credentials needed) or `404` (missing, or an |
| 188 | /// authenticated stranger — no existence leak). | 208 | /// authenticated stranger — no existence leak). |
| 189 | #[allow(clippy::result_large_err)] // the Err is a ready-to-send Response by design. | 209 | #[allow(clippy::result_large_err)] // the Err is a ready-to-send Response by design. |
| 190 | async fn authorize( | 210 | pub(crate) async fn authorize( |
| 191 | state: &AppState, | 211 | state: &AppState, |
| 192 | jar: &CookieJar, | 212 | jar: &CookieJar, |
| 193 | headers: &HeaderMap, | 213 | headers: &HeaderMap, |
| 194 | owner: &str, | 214 | owner: &str, |
| 195 | repo_path: &str, | 215 | repo_path: &str, |
| 216 | required: auth::Access, | ||
| 196 | ) -> Result<Repo, Response> { | 217 | ) -> Result<Repo, Response> { |
| 197 | let owner_user = state.store.user_by_username(owner).await.ok().flatten(); | 218 | let owner_user = state.store.user_by_username(owner).await.ok().flatten(); |
| 198 | let repo = match owner_user { | 219 | let repo = match owner_user { |
| @@ -229,7 +250,7 @@ async fn authorize( | |||
| 229 | collaborator, | 250 | collaborator, |
| 230 | state.allow_anonymous(), | 251 | state.allow_anonymous(), |
| 231 | ); | 252 | ); |
| 232 | if access >= auth::Access::Read { | 253 | if access >= required { |
| 233 | Ok(repo) | 254 | Ok(repo) |
| 234 | } else if viewer.is_none() { | 255 | } else if viewer.is_none() { |
| 235 | Err(unauthorized()) | 256 | Err(unauthorized()) |
| @@ -239,9 +260,14 @@ async fn authorize( | |||
| 239 | } | 260 | } |
| 240 | } | 261 | } |
| 241 | 262 | ||
| 242 | /// Authenticate a pack request via the session cookie or HTTP Basic (username + | 263 | /// Authenticate a request via the session cookie, an LFS bearer token (minted by |
| 243 | /// password). Returns the user, or `None` for anonymous. | 264 | /// SSH `git-lfs-authenticate`), or HTTP Basic (username + password). Returns the |
| 244 | async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> Option<User> { | 265 | /// user, or `None` for anonymous. |
| 266 | pub(crate) async fn authenticate( | ||
| 267 | state: &AppState, | ||
| 268 | jar: &CookieJar, | ||
| 269 | headers: &HeaderMap, | ||
| 270 | ) -> Option<User> { | ||
| 245 | if let Some(cookie) = jar.get(&state.config.auth.cookie_name) | 271 | if let Some(cookie) = jar.get(&state.config.auth.cookie_name) |
| 246 | && let Ok(Some(user)) = state | 272 | && let Ok(Some(user)) = state |
| 247 | .store | 273 | .store |
| @@ -250,6 +276,9 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> | |||
| 250 | { | 276 | { |
| 251 | return Some(user); | 277 | return Some(user); |
| 252 | } | 278 | } |
| 279 | if let Some(user) = bearer_user(state, headers).await { | ||
| 280 | return Some(user); | ||
| 281 | } | ||
| 253 | let (username, password) = parse_basic(headers)?; | 282 | let (username, password) = parse_basic(headers)?; |
| 254 | let user = state | 283 | let user = state |
| 255 | .store | 284 | .store |
| @@ -267,6 +296,23 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> | |||
| 267 | } | 296 | } |
| 268 | } | 297 | } |
| 269 | 298 | ||
| 299 | /// Resolve an `Authorization: Bearer <jwt>` header to its user. The token is an | ||
| 300 | /// LFS access token minted by SSH `git-lfs-authenticate`; only its signature and | ||
| 301 | /// expiry are proven here (access is re-checked per repo by [`authorize`]). | ||
| 302 | async fn bearer_user(state: &AppState, headers: &HeaderMap) -> Option<User> { | ||
| 303 | let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; | ||
| 304 | let token = value.strip_prefix("Bearer ")?.trim(); | ||
| 305 | let now = crate::now_ms() / 1000; | ||
| 306 | let claims = auth::verify_jwt(state.secret.as_str(), token, now).ok()?; | ||
| 307 | state | ||
| 308 | .store | ||
| 309 | .user_by_id(&claims.sub) | ||
| 310 | .await | ||
| 311 | .ok() | ||
| 312 | .flatten() | ||
| 313 | .filter(|u| u.disabled_at.is_none()) | ||
| 314 | } | ||
| 315 | |||
| 270 | /// Parse an HTTP Basic `Authorization` header into `(username, password)`. | 316 | /// Parse an HTTP Basic `Authorization` header into `(username, password)`. |
| 271 | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { | 317 | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { |
| 272 | let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; | 318 | let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; |
crates/web/src/lfs.rs +534 −0
| @@ -0,0 +1,534 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Git-LFS: the [Batch API] and basic transfer (upload / download) over HTTP. | ||
| 6 | //! | ||
| 7 | //! Objects are content-addressed by their sha256 `oid` and stored on disk under | ||
| 8 | //! `storage.lfs_dir`, sharded `{oid[0..2]}/{oid[2..4]}/{oid}`; the `lfs_objects` | ||
| 9 | //! table records which repository holds which object (for access control and the | ||
| 10 | //! batch "do you have this?" answer). Uploads stream to a temp file, are verified | ||
| 11 | //! against the claimed oid, and only then moved into place — a corrupt or | ||
| 12 | //! mislabelled upload never becomes a servable object. | ||
| 13 | //! | ||
| 14 | //! Authorization runs before any bytes move: download needs [`Access::Read`], | ||
| 15 | //! upload needs [`Access::Write`]. HTTPS clients authenticate with Basic | ||
| 16 | //! credentials; SSH clients present the bearer token minted by | ||
| 17 | //! `git-lfs-authenticate` (see [`crate::git_http::authenticate`]). | ||
| 18 | //! | ||
| 19 | //! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md | ||
| 20 | |||
| 21 | use std::collections::HashMap; | ||
| 22 | use std::path::PathBuf; | ||
| 23 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 24 | |||
| 25 | use axum::body::Body; | ||
| 26 | use axum::extract::{Path, State}; | ||
| 27 | use axum::http::{HeaderMap, StatusCode, header}; | ||
| 28 | use axum::response::{IntoResponse, Response}; | ||
| 29 | use axum_extra::extract::cookie::CookieJar; | ||
| 30 | use futures_util::TryStreamExt as _; | ||
| 31 | use serde::{Deserialize, Serialize}; | ||
| 32 | use sha2::{Digest, Sha256}; | ||
| 33 | use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; | ||
| 34 | use tokio_util::io::{ReaderStream, StreamReader}; | ||
| 35 | |||
| 36 | use crate::AppState; | ||
| 37 | use crate::git_http::authorize; | ||
| 38 | |||
| 39 | /// The media type every LFS JSON request and response uses. | ||
| 40 | const LFS_JSON: &str = "application/vnd.git-lfs+json"; | ||
| 41 | |||
| 42 | /// Per-process counter for unique upload temp-file names. | ||
| 43 | static TMP_SEQ: AtomicU64 = AtomicU64::new(0); | ||
| 44 | |||
| 45 | /// Split a repo-relative path on `/info/lfs/`, returning `(repo_path, lfs_tail)` | ||
| 46 | /// with any trailing `.git` stripped from the repo path. `lfs_tail` is e.g. | ||
| 47 | /// `objects/batch` or `objects/<oid>`. | ||
| 48 | #[must_use] | ||
| 49 | pub(crate) fn split_lfs(rest: &str) -> Option<(&str, &str)> { | ||
| 50 | let idx = rest.find("/info/lfs/")?; | ||
| 51 | let repo = rest[..idx].strip_suffix(".git").unwrap_or(&rest[..idx]); | ||
| 52 | Some((repo, &rest[idx + "/info/lfs/".len()..])) | ||
| 53 | } | ||
| 54 | |||
| 55 | /// A 64-char lowercase-hex sha256 is the only shape we treat as an oid; anything | ||
| 56 | /// else is rejected before it can touch the filesystem. | ||
| 57 | fn valid_oid(oid: &str) -> bool { | ||
| 58 | oid.len() == 64 | ||
| 59 | && oid | ||
| 60 | .bytes() | ||
| 61 | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) | ||
| 62 | } | ||
| 63 | |||
| 64 | /// The on-disk path for an object, sharded by the first two byte-pairs of the oid. | ||
| 65 | fn object_path(lfs_dir: &std::path::Path, oid: &str) -> PathBuf { | ||
| 66 | lfs_dir.join(&oid[0..2]).join(&oid[2..4]).join(oid) | ||
| 67 | } | ||
| 68 | |||
| 69 | /// Hex-encode a byte slice (lowercase), for comparing a computed digest to an oid. | ||
| 70 | fn hex(bytes: &[u8]) -> String { | ||
| 71 | let mut s = String::with_capacity(bytes.len() * 2); | ||
| 72 | for b in bytes { | ||
| 73 | s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0')); | ||
| 74 | s.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0')); | ||
| 75 | } | ||
| 76 | s | ||
| 77 | } | ||
| 78 | |||
| 79 | /// `PUT /{owner}/{*rest}` — the object-upload route. Recognizes only the LFS | ||
| 80 | /// `…/info/lfs/objects/{oid}` shape and rejects everything else. | ||
| 81 | pub(crate) async fn put( | ||
| 82 | State(state): State<AppState>, | ||
| 83 | jar: CookieJar, | ||
| 84 | headers: HeaderMap, | ||
| 85 | Path((owner, rest)): Path<(String, String)>, | ||
| 86 | body: Body, | ||
| 87 | ) -> Response { | ||
| 88 | let Some((repo_path, tail)) = split_lfs(&rest) else { | ||
| 89 | return StatusCode::NOT_FOUND.into_response(); | ||
| 90 | }; | ||
| 91 | let Some(oid) = tail.strip_prefix("objects/") else { | ||
| 92 | return StatusCode::NOT_FOUND.into_response(); | ||
| 93 | }; | ||
| 94 | if oid.contains('/') { | ||
| 95 | return StatusCode::NOT_FOUND.into_response(); | ||
| 96 | } | ||
| 97 | upload( | ||
| 98 | state, | ||
| 99 | jar, | ||
| 100 | headers, | ||
| 101 | owner, | ||
| 102 | repo_path.to_string(), | ||
| 103 | oid.to_string(), | ||
| 104 | body, | ||
| 105 | ) | ||
| 106 | .await | ||
| 107 | } | ||
| 108 | |||
| 109 | // ---- Batch API ---- | ||
| 110 | |||
| 111 | /// One `{oid, size}` pair in a batch request. | ||
| 112 | #[derive(Debug, Deserialize)] | ||
| 113 | struct ObjectId { | ||
| 114 | oid: String, | ||
| 115 | #[serde(default)] | ||
| 116 | size: i64, | ||
| 117 | } | ||
| 118 | |||
| 119 | /// A `POST objects/batch` request body. | ||
| 120 | #[derive(Debug, Deserialize)] | ||
| 121 | struct BatchRequest { | ||
| 122 | operation: String, | ||
| 123 | objects: Vec<ObjectId>, | ||
| 124 | } | ||
| 125 | |||
| 126 | /// A single transfer action (an href the client PUTs to or GETs from). | ||
| 127 | #[derive(Debug, Serialize)] | ||
| 128 | struct Action { | ||
| 129 | href: String, | ||
| 130 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 131 | header: Option<HashMap<String, String>>, | ||
| 132 | } | ||
| 133 | |||
| 134 | /// The actions available for one object (only the relevant ones are set). | ||
| 135 | #[derive(Debug, Default, Serialize)] | ||
| 136 | struct Actions { | ||
| 137 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 138 | download: Option<Action>, | ||
| 139 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 140 | upload: Option<Action>, | ||
| 141 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 142 | verify: Option<Action>, | ||
| 143 | } | ||
| 144 | |||
| 145 | /// A per-object error in the batch response. | ||
| 146 | #[derive(Debug, Serialize)] | ||
| 147 | struct ObjectError { | ||
| 148 | code: u16, | ||
| 149 | message: String, | ||
| 150 | } | ||
| 151 | |||
| 152 | /// One object in the batch response. | ||
| 153 | #[derive(Debug, Serialize)] | ||
| 154 | struct BatchObject { | ||
| 155 | oid: String, | ||
| 156 | size: i64, | ||
| 157 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 158 | actions: Option<Actions>, | ||
| 159 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 160 | error: Option<ObjectError>, | ||
| 161 | } | ||
| 162 | |||
| 163 | /// The `POST objects/batch` response body. | ||
| 164 | #[derive(Debug, Serialize)] | ||
| 165 | struct BatchResponse { | ||
| 166 | transfer: String, | ||
| 167 | objects: Vec<BatchObject>, | ||
| 168 | } | ||
| 169 | |||
| 170 | /// `POST .../info/lfs/objects/batch` — negotiate which objects the client may | ||
| 171 | /// upload or download and where. | ||
| 172 | pub(crate) async fn batch( | ||
| 173 | state: AppState, | ||
| 174 | jar: CookieJar, | ||
| 175 | headers: HeaderMap, | ||
| 176 | owner: String, | ||
| 177 | repo_path: String, | ||
| 178 | body: Body, | ||
| 179 | ) -> Response { | ||
| 180 | let Ok(raw) = axum::body::to_bytes(body, 1024 * 1024).await else { | ||
| 181 | return lfs_error(StatusCode::BAD_REQUEST, "request too large"); | ||
| 182 | }; | ||
| 183 | let Ok(req) = serde_json::from_slice::<BatchRequest>(&raw) else { | ||
| 184 | return lfs_error(StatusCode::BAD_REQUEST, "malformed batch request"); | ||
| 185 | }; | ||
| 186 | let uploading = req.operation == "upload"; | ||
| 187 | let required = if uploading { | ||
| 188 | auth::Access::Write | ||
| 189 | } else { | ||
| 190 | auth::Access::Read | ||
| 191 | }; | ||
| 192 | let repo = match authorize(&state, &jar, &headers, &owner, &repo_path, required).await { | ||
| 193 | Ok(repo) => repo, | ||
| 194 | Err(resp) => return resp, | ||
| 195 | }; | ||
| 196 | |||
| 197 | let base = format!( | ||
| 198 | "{}/{}/{}.git/info/lfs/objects", | ||
| 199 | state.config.instance.url.trim_end_matches('/'), | ||
| 200 | owner, | ||
| 201 | repo_path | ||
| 202 | ); | ||
| 203 | let mut objects = Vec::with_capacity(req.objects.len()); | ||
| 204 | for obj in &req.objects { | ||
| 205 | objects.push(if uploading { | ||
| 206 | batch_upload(&state, &repo.id, &base, obj).await | ||
| 207 | } else { | ||
| 208 | batch_download(&state, &repo.id, &base, obj).await | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | let payload = BatchResponse { | ||
| 212 | transfer: "basic".to_string(), | ||
| 213 | objects, | ||
| 214 | }; | ||
| 215 | lfs_json(StatusCode::OK, &payload) | ||
| 216 | } | ||
| 217 | |||
| 218 | /// Resolve one object for a download batch: offer a download action if we hold it. | ||
| 219 | async fn batch_download( | ||
| 220 | state: &AppState, | ||
| 221 | repo_id: &str, | ||
| 222 | base: &str, | ||
| 223 | obj: &ObjectId, | ||
| 224 | ) -> BatchObject { | ||
| 225 | if valid_oid(&obj.oid) | ||
| 226 | && state | ||
| 227 | .store | ||
| 228 | .lfs_object_exists(repo_id, &obj.oid) | ||
| 229 | .await | ||
| 230 | .unwrap_or(false) | ||
| 231 | { | ||
| 232 | BatchObject { | ||
| 233 | oid: obj.oid.clone(), | ||
| 234 | size: obj.size, | ||
| 235 | actions: Some(Actions { | ||
| 236 | download: Some(Action { | ||
| 237 | href: format!("{base}/{}", obj.oid), | ||
| 238 | header: None, | ||
| 239 | }), | ||
| 240 | ..Actions::default() | ||
| 241 | }), | ||
| 242 | error: None, | ||
| 243 | } | ||
| 244 | } else { | ||
| 245 | BatchObject { | ||
| 246 | oid: obj.oid.clone(), | ||
| 247 | size: obj.size, | ||
| 248 | actions: None, | ||
| 249 | error: Some(ObjectError { | ||
| 250 | code: 404, | ||
| 251 | message: "object does not exist".to_string(), | ||
| 252 | }), | ||
| 253 | } | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 257 | /// Resolve one object for an upload batch: offer upload+verify unless we have it. | ||
| 258 | async fn batch_upload(state: &AppState, repo_id: &str, base: &str, obj: &ObjectId) -> BatchObject { | ||
| 259 | if !valid_oid(&obj.oid) { | ||
| 260 | return BatchObject { | ||
| 261 | oid: obj.oid.clone(), | ||
| 262 | size: obj.size, | ||
| 263 | actions: None, | ||
| 264 | error: Some(ObjectError { | ||
| 265 | code: 422, | ||
| 266 | message: "invalid oid".to_string(), | ||
| 267 | }), | ||
| 268 | }; | ||
| 269 | } | ||
| 270 | // Already stored (in this repo): no action needed, the client skips it. | ||
| 271 | if state | ||
| 272 | .store | ||
| 273 | .lfs_object_exists(repo_id, &obj.oid) | ||
| 274 | .await | ||
| 275 | .unwrap_or(false) | ||
| 276 | { | ||
| 277 | return BatchObject { | ||
| 278 | oid: obj.oid.clone(), | ||
| 279 | size: obj.size, | ||
| 280 | actions: None, | ||
| 281 | error: None, | ||
| 282 | }; | ||
| 283 | } | ||
| 284 | BatchObject { | ||
| 285 | oid: obj.oid.clone(), | ||
| 286 | size: obj.size, | ||
| 287 | actions: Some(Actions { | ||
| 288 | upload: Some(Action { | ||
| 289 | href: format!("{base}/{}", obj.oid), | ||
| 290 | header: None, | ||
| 291 | }), | ||
| 292 | verify: Some(Action { | ||
| 293 | href: format!("{base}/{}/verify", obj.oid), | ||
| 294 | header: None, | ||
| 295 | }), | ||
| 296 | ..Actions::default() | ||
| 297 | }), | ||
| 298 | error: None, | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | // ---- Basic transfer ---- | ||
| 303 | |||
| 304 | /// `GET .../info/lfs/objects/{oid}` — stream a stored object. | ||
| 305 | pub(crate) async fn download( | ||
| 306 | state: AppState, | ||
| 307 | jar: CookieJar, | ||
| 308 | headers: HeaderMap, | ||
| 309 | owner: String, | ||
| 310 | repo_path: String, | ||
| 311 | oid: String, | ||
| 312 | ) -> Response { | ||
| 313 | let repo = match authorize( | ||
| 314 | &state, | ||
| 315 | &jar, | ||
| 316 | &headers, | ||
| 317 | &owner, | ||
| 318 | &repo_path, | ||
| 319 | auth::Access::Read, | ||
| 320 | ) | ||
| 321 | .await | ||
| 322 | { | ||
| 323 | Ok(repo) => repo, | ||
| 324 | Err(resp) => return resp, | ||
| 325 | }; | ||
| 326 | if !valid_oid(&oid) | ||
| 327 | || !state | ||
| 328 | .store | ||
| 329 | .lfs_object_exists(&repo.id, &oid) | ||
| 330 | .await | ||
| 331 | .unwrap_or(false) | ||
| 332 | { | ||
| 333 | return StatusCode::NOT_FOUND.into_response(); | ||
| 334 | } | ||
| 335 | let path = object_path(&state.config.storage.lfs_dir, &oid); | ||
| 336 | let Ok(file) = tokio::fs::File::open(&path).await else { | ||
| 337 | return StatusCode::NOT_FOUND.into_response(); | ||
| 338 | }; | ||
| 339 | let len = file.metadata().await.map_or(0, |m| m.len()); | ||
| 340 | let body = Body::from_stream(ReaderStream::new(file)); | ||
| 341 | ( | ||
| 342 | [ | ||
| 343 | (header::CONTENT_TYPE, "application/octet-stream".to_string()), | ||
| 344 | (header::CONTENT_LENGTH, len.to_string()), | ||
| 345 | ], | ||
| 346 | body, | ||
| 347 | ) | ||
| 348 | .into_response() | ||
| 349 | } | ||
| 350 | |||
| 351 | /// `PUT .../info/lfs/objects/{oid}` — receive an object, verifying its sha256 | ||
| 352 | /// matches the oid before it becomes servable. | ||
| 353 | pub(crate) async fn upload( | ||
| 354 | state: AppState, | ||
| 355 | jar: CookieJar, | ||
| 356 | headers: HeaderMap, | ||
| 357 | owner: String, | ||
| 358 | repo_path: String, | ||
| 359 | oid: String, | ||
| 360 | body: Body, | ||
| 361 | ) -> Response { | ||
| 362 | let repo = match authorize( | ||
| 363 | &state, | ||
| 364 | &jar, | ||
| 365 | &headers, | ||
| 366 | &owner, | ||
| 367 | &repo_path, | ||
| 368 | auth::Access::Write, | ||
| 369 | ) | ||
| 370 | .await | ||
| 371 | { | ||
| 372 | Ok(repo) => repo, | ||
| 373 | Err(resp) => return resp, | ||
| 374 | }; | ||
| 375 | if !valid_oid(&oid) { | ||
| 376 | return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid oid"); | ||
| 377 | } | ||
| 378 | // Idempotent: if we already hold it, drain the body and succeed. | ||
| 379 | if state | ||
| 380 | .store | ||
| 381 | .lfs_object_exists(&repo.id, &oid) | ||
| 382 | .await | ||
| 383 | .unwrap_or(false) | ||
| 384 | { | ||
| 385 | return StatusCode::OK.into_response(); | ||
| 386 | } | ||
| 387 | |||
| 388 | let lfs_dir = &state.config.storage.lfs_dir; | ||
| 389 | let tmp_dir = lfs_dir.join("tmp"); | ||
| 390 | if tokio::fs::create_dir_all(&tmp_dir).await.is_err() { | ||
| 391 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); | ||
| 392 | } | ||
| 393 | let tmp = tmp_dir.join(format!( | ||
| 394 | "{}.{}.tmp", | ||
| 395 | std::process::id(), | ||
| 396 | TMP_SEQ.fetch_add(1, Ordering::Relaxed) | ||
| 397 | )); | ||
| 398 | |||
| 399 | let Ok((digest, size)) = stream_to_file(body, &tmp).await else { | ||
| 400 | let _ = tokio::fs::remove_file(&tmp).await; | ||
| 401 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); | ||
| 402 | }; | ||
| 403 | if digest != oid { | ||
| 404 | let _ = tokio::fs::remove_file(&tmp).await; | ||
| 405 | return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "oid mismatch"); | ||
| 406 | } | ||
| 407 | |||
| 408 | let dest = object_path(lfs_dir, &oid); | ||
| 409 | if let Some(parent) = dest.parent() { | ||
| 410 | let _ = tokio::fs::create_dir_all(parent).await; | ||
| 411 | } | ||
| 412 | if tokio::fs::rename(&tmp, &dest).await.is_err() { | ||
| 413 | let _ = tokio::fs::remove_file(&tmp).await; | ||
| 414 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); | ||
| 415 | } | ||
| 416 | if state | ||
| 417 | .store | ||
| 418 | .lfs_object_add(&repo.id, &oid, size) | ||
| 419 | .await | ||
| 420 | .is_err() | ||
| 421 | { | ||
| 422 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); | ||
| 423 | } | ||
| 424 | StatusCode::OK.into_response() | ||
| 425 | } | ||
| 426 | |||
| 427 | /// `POST .../info/lfs/objects/{oid}/verify` — confirm an object landed at the | ||
| 428 | /// expected size (git-lfs calls this after an upload). | ||
| 429 | pub(crate) async fn verify( | ||
| 430 | state: AppState, | ||
| 431 | jar: CookieJar, | ||
| 432 | headers: HeaderMap, | ||
| 433 | owner: String, | ||
| 434 | repo_path: String, | ||
| 435 | body: Body, | ||
| 436 | ) -> Response { | ||
| 437 | let repo = match authorize( | ||
| 438 | &state, | ||
| 439 | &jar, | ||
| 440 | &headers, | ||
| 441 | &owner, | ||
| 442 | &repo_path, | ||
| 443 | auth::Access::Read, | ||
| 444 | ) | ||
| 445 | .await | ||
| 446 | { | ||
| 447 | Ok(repo) => repo, | ||
| 448 | Err(resp) => return resp, | ||
| 449 | }; | ||
| 450 | let Ok(raw) = axum::body::to_bytes(body, 64 * 1024).await else { | ||
| 451 | return lfs_error(StatusCode::BAD_REQUEST, "request too large"); | ||
| 452 | }; | ||
| 453 | let Ok(obj) = serde_json::from_slice::<ObjectId>(&raw) else { | ||
| 454 | return lfs_error(StatusCode::BAD_REQUEST, "malformed request"); | ||
| 455 | }; | ||
| 456 | match state.store.lfs_object_size(&repo.id, &obj.oid).await { | ||
| 457 | Ok(Some(size)) if size == obj.size => StatusCode::OK.into_response(), | ||
| 458 | _ => lfs_error(StatusCode::NOT_FOUND, "object not found"), | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | /// Stream `body` into `path`, returning `(hex_sha256, size)`. | ||
| 463 | async fn stream_to_file(body: Body, path: &std::path::Path) -> std::io::Result<(String, i64)> { | ||
| 464 | let stream = body.into_data_stream().map_err(std::io::Error::other); | ||
| 465 | let reader = StreamReader::new(stream); | ||
| 466 | tokio::pin!(reader); | ||
| 467 | let mut file = tokio::fs::File::create(path).await?; | ||
| 468 | let mut hasher = Sha256::new(); | ||
| 469 | let mut buf = vec![0u8; 64 * 1024]; | ||
| 470 | let mut size: i64 = 0; | ||
| 471 | loop { | ||
| 472 | let n = reader.read(&mut buf).await?; | ||
| 473 | if n == 0 { | ||
| 474 | break; | ||
| 475 | } | ||
| 476 | hasher.update(&buf[..n]); | ||
| 477 | file.write_all(&buf[..n]).await?; | ||
| 478 | size += i64::try_from(n).unwrap_or(0); | ||
| 479 | } | ||
| 480 | file.flush().await?; | ||
| 481 | Ok((hex(&hasher.finalize()), size)) | ||
| 482 | } | ||
| 483 | |||
| 484 | /// Render a value as an LFS JSON response with the required content type. | ||
| 485 | fn lfs_json<T: Serialize>(status: StatusCode, value: &T) -> Response { | ||
| 486 | match serde_json::to_vec(value) { | ||
| 487 | Ok(bytes) => (status, [(header::CONTENT_TYPE, LFS_JSON)], bytes).into_response(), | ||
| 488 | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), | ||
| 489 | } | ||
| 490 | } | ||
| 491 | |||
| 492 | /// Render an LFS error body (`{"message": "..."}`) with the given status. | ||
| 493 | fn lfs_error(status: StatusCode, message: &str) -> Response { | ||
| 494 | let body = serde_json::json!({ "message": message }); | ||
| 495 | (status, [(header::CONTENT_TYPE, LFS_JSON)], body.to_string()).into_response() | ||
| 496 | } | ||
| 497 | |||
| 498 | #[cfg(test)] | ||
| 499 | mod tests { | ||
| 500 | use super::*; | ||
| 501 | |||
| 502 | #[test] | ||
| 503 | fn split_lfs_finds_the_repo_and_tail() { | ||
| 504 | assert_eq!( | ||
| 505 | split_lfs("owner-scope/repo.git/info/lfs/objects/batch"), | ||
| 506 | Some(("owner-scope/repo", "objects/batch")) | ||
| 507 | ); | ||
| 508 | // The `.git` is optional (git-lfs derives the endpoint from the remote). | ||
| 509 | assert_eq!( | ||
| 510 | split_lfs("grp/sub/repo/info/lfs/objects/abc"), | ||
| 511 | Some(("grp/sub/repo", "objects/abc")) | ||
| 512 | ); | ||
| 513 | assert_eq!(split_lfs("owner/repo.git/info/refs"), None); | ||
| 514 | } | ||
| 515 | |||
| 516 | #[test] | ||
| 517 | fn valid_oid_accepts_only_lowercase_hex_sha256() { | ||
| 518 | assert!(valid_oid(&"a".repeat(64))); | ||
| 519 | assert!(valid_oid(&"0123456789abcdef".repeat(4))); | ||
| 520 | assert!(!valid_oid(&"a".repeat(63)), "too short"); | ||
| 521 | assert!(!valid_oid(&"A".repeat(64)), "uppercase rejected"); | ||
| 522 | assert!(!valid_oid(&"g".repeat(64)), "non-hex rejected"); | ||
| 523 | } | ||
| 524 | |||
| 525 | #[test] | ||
| 526 | fn hex_matches_a_known_sha256() { | ||
| 527 | // sha256("") = e3b0c442... | ||
| 528 | let digest = Sha256::digest(b""); | ||
| 529 | assert_eq!( | ||
| 530 | hex(&digest), | ||
| 531 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" | ||
| 532 | ); | ||
| 533 | } | ||
| 534 | } | ||
crates/web/src/lib.rs +2 −1
| @@ -21,6 +21,7 @@ mod icons; | |||
| 21 | mod issues; | 21 | mod issues; |
| 22 | mod languages; | 22 | mod languages; |
| 23 | mod layout; | 23 | mod layout; |
| 24 | mod lfs; | ||
| 24 | mod license; | 25 | mod license; |
| 25 | mod markdown; | 26 | mod markdown; |
| 26 | mod pages; | 27 | mod pages; |
| @@ -323,7 +324,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 323 | .route("/{owner}", get(repo::user_page)) | 324 | .route("/{owner}", get(repo::user_page)) |
| 324 | .route( | 325 | .route( |
| 325 | "/{owner}/{*rest}", | 326 | "/{owner}/{*rest}", |
| 326 | get(repo::dispatch).post(git_http::pack_rpc), | 327 | get(repo::dispatch).post(git_http::pack_rpc).put(lfs::put), |
| 327 | ) | 328 | ) |
| 328 | .layer( | 329 | .layer( |
| 329 | ServiceBuilder::new() | 330 | ServiceBuilder::new() |
crates/web/src/repo.rs +17 −0
| @@ -341,6 +341,23 @@ pub async fn dispatch( | |||
| 341 | Query(dq): Query<DiffQuery>, | 341 | Query(dq): Query<DiffQuery>, |
| 342 | Path((owner_name, rest)): Path<(String, String)>, | 342 | Path((owner_name, rest)): Path<(String, String)>, |
| 343 | ) -> AppResult<Response> { | 343 | ) -> AppResult<Response> { |
| 344 | // LFS object download shares the GET catch-all (`…/info/lfs/objects/{oid}`). | ||
| 345 | if !rest.contains("/-/") | ||
| 346 | && let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) | ||
| 347 | && let Some(oid) = tail.strip_prefix("objects/") | ||
| 348 | && !oid.contains('/') | ||
| 349 | { | ||
| 350 | return Ok(crate::lfs::download( | ||
| 351 | state, | ||
| 352 | jar, | ||
| 353 | headers, | ||
| 354 | owner_name, | ||
| 355 | repo_path.to_string(), | ||
| 356 | oid.to_string(), | ||
| 357 | ) | ||
| 358 | .await); | ||
| 359 | } | ||
| 360 | |||
| 344 | // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob | 361 | // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob |
| 345 | // path that merely contains ".git/" is never misrouted (those carry "/-/"). | 362 | // path that merely contains ".git/" is never misrouted (those carry "/-/"). |
| 346 | if !rest.contains("/-/") | 363 | if !rest.contains("/-/") |
src/serve.rs +2 −1
| @@ -61,8 +61,9 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> { | |||
| 61 | let ssh_config = Arc::clone(&config); | 61 | let ssh_config = Arc::clone(&config); |
| 62 | let ssh_store = store.clone(); | 62 | let ssh_store = store.clone(); |
| 63 | let ssh_config_path = req.config_path.as_ref().map(|p| p.display().to_string()); | 63 | let ssh_config_path = req.config_path.as_ref().map(|p| p.display().to_string()); |
| 64 | let ssh_secret = secret.clone(); | ||
| 64 | let ssh_task = tokio::spawn(async move { | 65 | let ssh_task = tokio::spawn(async move { |
| 65 | if let Err(err) = ssh::serve(ssh_config, ssh_store, ssh_config_path).await { | 66 | if let Err(err) = ssh::serve(ssh_config, ssh_store, ssh_config_path, ssh_secret).await { |
| 66 | eprintln!("ssh server error: {err}"); | 67 | eprintln!("ssh server error: {err}"); |
| 67 | } | 68 | } |
| 68 | }); | 69 | }); |