fabrica

hanna/fabrica

feat(lfs): git-LFS batch API, transfer, and SSH authentication

a3d1c7c · hanna committed on 2026-07-26

Implement git-LFS end to end:

- HTTP endpoints under `…/info/lfs/`: the Batch API (objects/batch),
  basic transfer (GET/PUT objects/{oid}), and objects/{oid}/verify,
  wired into the repo catch-all routes. Uploads stream to a temp file,
  are verified against the claimed sha256 oid, and only then moved into
  the content-addressed store under storage.lfs_dir.
- Authorization runs before any bytes move (download = Read, upload =
  Write). git_http::authorize now takes the required Access level, and
  authenticate also accepts a Bearer JWT.
- SSH `git-lfs-authenticate` mints a one-hour bearer token and returns
  the HTTP LFS endpoint, so SSH-cloned repos transfer LFS over HTTP.
  The SSH server gains the signing secret for this.

Objects are tracked per-repo in lfs_objects; the bytes never enter the
git object database. Includes helper and store-level tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +782 −9UnifiedSplit
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 for157# Streaming the pack subprocess stdout into the HTTP response body; gunzip for
158# gzip-encoded upload-pack requests.158# gzip-encoded upload-pack requests.
159tokio-util = { version = "0.7", features = ["io"] }159tokio-util = { version = "0.7", features = ["io"] }
160# Stream adapters for LFS: turn the request body stream into an AsyncRead.
161futures-util = "0.3"
160flate2 = "1"162flate2 = "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.
162grep = "0.3"164grep = "0.3"
crates/ssh/src/command.rs +27 −0
@@ -40,6 +40,33 @@ pub fn parse(command: &str) -> Option<GitCommand> {
40 })40 })
41}41}
4242
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)]
46pub 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]
55pub 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 sends70/// 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.
45fn shell_split(input: &str) -> Option<Vec<String>> {72fn 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}
3032
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 }
257265
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}
826826
827#[tokio::test]827#[tokio::test]
828async 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]
828async fn keys_get_stable_per_user_ordinals() {863async fn keys_get_stable_per_user_ordinals() {
829 for fx in sqlite_fixtures().await {864 for fx in sqlite_fixtures().await {
830 let user = fx865 let user = fx
crates/web/Cargo.toml +2 −0
@@ -28,6 +28,8 @@ mime_guess = { workspace = true }
28comrak = { workspace = true }28comrak = { workspace = true }
29ammonia = { workspace = true }29ammonia = { workspace = true }
30tokio-util = { workspace = true }30tokio-util = { workspace = true }
31futures-util = { workspace = true }
32sha2 = { workspace = true }
31flate2 = { workspace = true }33flate2 = { workspace = true }
32grep = { workspace = true }34grep = { workspace = true }
33reqwest = { workspace = true }35reqwest = { 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 }
5252
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 }
7788
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 an207/// 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.
190async fn authorize(210pub(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}
241262
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
244async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> Option<User> {265/// user, or `None` for anonymous.
266pub(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)) = state272 && let Ok(Some(user)) = state
247 .store273 .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 = state283 let user = state
255 .store284 .store
@@ -267,6 +296,23 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) ->
267 }296 }
268}297}
269298
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`]).
302async 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)`.
271fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {317fn 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
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use axum::body::Body;
26use axum::extract::{Path, State};
27use axum::http::{HeaderMap, StatusCode, header};
28use axum::response::{IntoResponse, Response};
29use axum_extra::extract::cookie::CookieJar;
30use futures_util::TryStreamExt as _;
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
34use tokio_util::io::{ReaderStream, StreamReader};
35
36use crate::AppState;
37use crate::git_http::authorize;
38
39/// The media type every LFS JSON request and response uses.
40const LFS_JSON: &str = "application/vnd.git-lfs+json";
41
42/// Per-process counter for unique upload temp-file names.
43static 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]
49pub(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.
57fn 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.
65fn 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.
70fn 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.
81pub(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)]
113struct ObjectId {
114 oid: String,
115 #[serde(default)]
116 size: i64,
117}
118
119/// A `POST objects/batch` request body.
120#[derive(Debug, Deserialize)]
121struct 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)]
128struct 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)]
136struct 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)]
147struct ObjectError {
148 code: u16,
149 message: String,
150}
151
152/// One object in the batch response.
153#[derive(Debug, Serialize)]
154struct 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)]
165struct 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.
172pub(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.
219async 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.
258async 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.
305pub(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.
353pub(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).
429pub(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)`.
463async 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.
485fn 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.
493fn 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)]
499mod 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;
21mod issues;21mod issues;
22mod languages;22mod languages;
23mod layout;23mod layout;
24mod lfs;
24mod license;25mod license;
25mod markdown;26mod markdown;
26mod pages;27mod 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 blob361 // 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 });