Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +2 −0
| @@ -5707,6 +5707,7 @@ dependencies = [ | |||
| 5707 | 5707 | "comrak", | |
| 5708 | 5708 | "config", | |
| 5709 | 5709 | "flate2", | |
| 5710 | + | "futures-util", | |
| 5710 | 5711 | "git", | |
| 5711 | 5712 | "git2", | |
| 5712 | 5713 | "grep", | |
| @@ -5719,6 +5720,7 @@ dependencies = [ | |||
| 5719 | 5720 | "rust-embed", | |
| 5720 | 5721 | "serde", | |
| 5721 | 5722 | "serde_json", | |
| 5723 | + | "sha2 0.10.9", | |
| 5722 | 5724 | "store", | |
| 5723 | 5725 | "tempfile", | |
| 5724 | 5726 | "thiserror 2.0.19", | |
Cargo.toml +2 −0
| @@ -157,6 +157,8 @@ ammonia = "4" | |||
| 157 | 157 | # Streaming the pack subprocess stdout into the HTTP response body; gunzip for | |
| 158 | 158 | # gzip-encoded upload-pack requests. | |
| 159 | 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 | 162 | flate2 = "1" | |
| 161 | 163 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | |
| 162 | 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 | 70 | /// Split a command line into words, honouring single and double quotes (git sends | |
| 44 | 71 | /// the path single-quoted). Returns `None` on an unterminated quote. | |
| 45 | 72 | fn shell_split(input: &str) -> Option<Vec<String>> { | |
crates/ssh/src/server.rs +106 −0
| @@ -26,6 +26,8 @@ struct Shared { | |||
| 26 | 26 | config: Arc<Config>, | |
| 27 | 27 | /// The `--config` path to hand hooks via `FABRICA_CONFIG`. | |
| 28 | 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 | 33 | /// The russh `Server` factory. | |
| @@ -188,6 +190,12 @@ impl GitHandler { | |||
| 188 | 190 | let Some(identity) = self.identity.clone() else { | |
| 189 | 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 | 199 | let parsed = command::parse(command).ok_or_else(|| self.no_shell_banner())?; | |
| 192 | 200 | let (owner, repo_path) = resolve::normalize(&parsed.path) | |
| 193 | 201 | .ok_or_else(|| "Invalid repository path.".to_string())?; | |
| @@ -255,6 +263,102 @@ impl GitHandler { | |||
| 255 | 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 | 362 | /// Resolve and authorize the repo for the requested service. | |
| 259 | 363 | async fn resolve_repo( | |
| 260 | 364 | &self, | |
| @@ -399,6 +503,7 @@ pub async fn serve( | |||
| 399 | 503 | config: Arc<Config>, | |
| 400 | 504 | store: Store, | |
| 401 | 505 | config_path: Option<String>, | |
| 506 | + | secret: String, | |
| 402 | 507 | ) -> Result<(), SshError> { | |
| 403 | 508 | if !config.ssh.enabled { | |
| 404 | 509 | tracing::info!("ssh disabled (ssh.enabled = false)"); | |
| @@ -420,6 +525,7 @@ pub async fn serve( | |||
| 420 | 525 | store, | |
| 421 | 526 | config: Arc::clone(&config), | |
| 422 | 527 | config_path, | |
| 528 | + | secret, | |
| 423 | 529 | }); | |
| 424 | 530 | let addr = (config.ssh.address.clone(), config.ssh.port); | |
| 425 | 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 | 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 | 863 | async fn keys_get_stable_per_user_ordinals() { | |
| 829 | 864 | for fx in sqlite_fixtures().await { | |
| 830 | 865 | let user = fx | |
crates/web/Cargo.toml +2 −0
| @@ -28,6 +28,8 @@ mime_guess = { workspace = true } | |||
| 28 | 28 | comrak = { workspace = true } | |
| 29 | 29 | ammonia = { workspace = true } | |
| 30 | 30 | tokio-util = { workspace = true } | |
| 31 | + | futures-util = { workspace = true } | |
| 32 | + | sha2 = { workspace = true } | |
| 31 | 33 | flate2 = { workspace = true } | |
| 32 | 34 | grep = { workspace = true } | |
| 33 | 35 | reqwest = { workspace = true } | |
crates/web/src/git_http.rs +53 −7
| @@ -50,7 +50,7 @@ pub async fn http_get( | |||
| 50 | 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 | 54 | Ok(repo) => repo, | |
| 55 | 55 | Err(response) => return response, | |
| 56 | 56 | }; | |
| @@ -66,6 +66,17 @@ pub async fn pack_rpc( | |||
| 66 | 66 | Path((owner, rest)): Path<(String, String)>, | |
| 67 | 67 | body: Body, | |
| 68 | 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 | 80 | let Some((repo_path, tail)) = split_git(&rest) else { | |
| 70 | 81 | return StatusCode::NOT_FOUND.into_response(); | |
| 71 | 82 | }; | |
| @@ -75,7 +86,16 @@ pub async fn pack_rpc( | |||
| 75 | 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 | 99 | Ok(repo) => repo, | |
| 80 | 100 | Err(response) => return response, | |
| 81 | 101 | }; | |
| @@ -187,12 +207,13 @@ async fn upload_pack_rpc( | |||
| 187 | 207 | /// to send: `401` (anonymous, credentials needed) or `404` (missing, or an | |
| 188 | 208 | /// authenticated stranger — no existence leak). | |
| 189 | 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 | 211 | state: &AppState, | |
| 192 | 212 | jar: &CookieJar, | |
| 193 | 213 | headers: &HeaderMap, | |
| 194 | 214 | owner: &str, | |
| 195 | 215 | repo_path: &str, | |
| 216 | + | required: auth::Access, | |
| 196 | 217 | ) -> Result<Repo, Response> { | |
| 197 | 218 | let owner_user = state.store.user_by_username(owner).await.ok().flatten(); | |
| 198 | 219 | let repo = match owner_user { | |
| @@ -229,7 +250,7 @@ async fn authorize( | |||
| 229 | 250 | collaborator, | |
| 230 | 251 | state.allow_anonymous(), | |
| 231 | 252 | ); | |
| 232 | - | if access >= auth::Access::Read { | |
| 253 | + | if access >= required { | |
| 233 | 254 | Ok(repo) | |
| 234 | 255 | } else if viewer.is_none() { | |
| 235 | 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 + | |
| 243 | - | /// password). Returns the user, or `None` for anonymous. | |
| 244 | - | async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> Option<User> { | |
| 263 | + | /// Authenticate a request via the session cookie, an LFS bearer token (minted by | |
| 264 | + | /// SSH `git-lfs-authenticate`), or HTTP Basic (username + password). Returns the | |
| 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 | 271 | if let Some(cookie) = jar.get(&state.config.auth.cookie_name) | |
| 246 | 272 | && let Ok(Some(user)) = state | |
| 247 | 273 | .store | |
| @@ -250,6 +276,9 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> | |||
| 250 | 276 | { | |
| 251 | 277 | return Some(user); | |
| 252 | 278 | } | |
| 279 | + | if let Some(user) = bearer_user(state, headers).await { | |
| 280 | + | return Some(user); | |
| 281 | + | } | |
| 253 | 282 | let (username, password) = parse_basic(headers)?; | |
| 254 | 283 | let user = state | |
| 255 | 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 | 316 | /// Parse an HTTP Basic `Authorization` header into `(username, password)`. | |
| 271 | 317 | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { | |
| 272 | 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 | 21 | mod issues; | |
| 22 | 22 | mod languages; | |
| 23 | 23 | mod layout; | |
| 24 | + | mod lfs; | |
| 24 | 25 | mod license; | |
| 25 | 26 | mod markdown; | |
| 26 | 27 | mod pages; | |
| @@ -323,7 +324,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 323 | 324 | .route("/{owner}", get(repo::user_page)) | |
| 324 | 325 | .route( | |
| 325 | 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 | 329 | .layer( | |
| 329 | 330 | ServiceBuilder::new() | |
crates/web/src/repo.rs +17 −0
| @@ -341,6 +341,23 @@ pub async fn dispatch( | |||
| 341 | 341 | Query(dq): Query<DiffQuery>, | |
| 342 | 342 | Path((owner_name, rest)): Path<(String, String)>, | |
| 343 | 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 | 361 | // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob | |
| 345 | 362 | // path that merely contains ".git/" is never misrouted (those carry "/-/"). | |
| 346 | 363 | if !rest.contains("/-/") | |
src/serve.rs +2 −1
| @@ -61,8 +61,9 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> { | |||
| 61 | 61 | let ssh_config = Arc::clone(&config); | |
| 62 | 62 | let ssh_store = store.clone(); | |
| 63 | 63 | let ssh_config_path = req.config_path.as_ref().map(|p| p.display().to_string()); | |
| 64 | + | let ssh_secret = secret.clone(); | |
| 64 | 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 | 67 | eprintln!("ssh server error: {err}"); | |
| 67 | 68 | } | |
| 68 | 69 | }); | |