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 = [
57075707 "comrak",
57085708 "config",
57095709 "flate2",
5710+ "futures-util",
57105711 "git",
57115712 "git2",
57125713 "grep",
@@ -5719,6 +5720,7 @@ dependencies = [
57195720 "rust-embed",
57205721 "serde",
57215722 "serde_json",
5723+ "sha2 0.10.9",
57225724 "store",
57235725 "tempfile",
57245726 "thiserror 2.0.19",
Cargo.toml +2 −0
@@ -157,6 +157,8 @@ ammonia = "4"
157157 # Streaming the pack subprocess stdout into the HTTP response body; gunzip for
158158 # gzip-encoded upload-pack requests.
159159 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"
160162 flate2 = "1"
161163 # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.
162164 grep = "0.3"
crates/ssh/src/command.rs +27 −0
@@ -40,6 +40,33 @@ pub fn parse(command: &str) -> Option<GitCommand> {
4040 })
4141 }
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)]
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+
4370 /// Split a command line into words, honouring single and double quotes (git sends
4471 /// the path single-quoted). Returns `None` on an unterminated quote.
4572 fn shell_split(input: &str) -> Option<Vec<String>> {
crates/ssh/src/server.rs +106 −0
@@ -26,6 +26,8 @@ struct Shared {
2626 config: Arc<Config>,
2727 /// The `--config` path to hand hooks via `FABRICA_CONFIG`.
2828 config_path: Option<String>,
29+ /// The instance signing secret, for minting short-lived LFS bearer tokens.
30+ secret: String,
2931 }
3032
3133 /// The russh `Server` factory.
@@ -188,6 +190,12 @@ impl GitHandler {
188190 let Some(identity) = self.identity.clone() else {
189191 return Err("Authentication required.".to_string());
190192 };
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+ }
191199 let parsed = command::parse(command).ok_or_else(|| self.no_shell_banner())?;
192200 let (owner, repo_path) = resolve::normalize(&parsed.path)
193201 .ok_or_else(|| "Invalid repository path.".to_string())?;
@@ -255,6 +263,102 @@ impl GitHandler {
255263 Ok(())
256264 }
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+
258362 /// Resolve and authorize the repo for the requested service.
259363 async fn resolve_repo(
260364 &self,
@@ -399,6 +503,7 @@ pub async fn serve(
399503 config: Arc<Config>,
400504 store: Store,
401505 config_path: Option<String>,
506+ secret: String,
402507 ) -> Result<(), SshError> {
403508 if !config.ssh.enabled {
404509 tracing::info!("ssh disabled (ssh.enabled = false)");
@@ -420,6 +525,7 @@ pub async fn serve(
420525 store,
421526 config: Arc::clone(&config),
422527 config_path,
528+ secret,
423529 });
424530 let addr = (config.ssh.address.clone(), config.ssh.port);
425531 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() {
825825 }
826826
827827 #[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]
828863 async fn keys_get_stable_per_user_ordinals() {
829864 for fx in sqlite_fixtures().await {
830865 let user = fx
crates/web/Cargo.toml +2 −0
@@ -28,6 +28,8 @@ mime_guess = { workspace = true }
2828 comrak = { workspace = true }
2929 ammonia = { workspace = true }
3030 tokio-util = { workspace = true }
31+futures-util = { workspace = true }
32+sha2 = { workspace = true }
3133 flate2 = { workspace = true }
3234 grep = { workspace = true }
3335 reqwest = { workspace = true }
crates/web/src/git_http.rs +53 −7
@@ -50,7 +50,7 @@ pub async fn http_get(
5050 _ => return StatusCode::FORBIDDEN.into_response(),
5151 }
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 {
5454 Ok(repo) => repo,
5555 Err(response) => return response,
5656 };
@@ -66,6 +66,17 @@ pub async fn pack_rpc(
6666 Path((owner, rest)): Path<(String, String)>,
6767 body: Body,
6868 ) -> 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+ }
6980 let Some((repo_path, tail)) = split_git(&rest) else {
7081 return StatusCode::NOT_FOUND.into_response();
7182 };
@@ -75,7 +86,16 @@ pub async fn pack_rpc(
7586 _ => return StatusCode::NOT_FOUND.into_response(),
7687 }
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+ {
7999 Ok(repo) => repo,
80100 Err(response) => return response,
81101 };
@@ -187,12 +207,13 @@ async fn upload_pack_rpc(
187207 /// to send: `401` (anonymous, credentials needed) or `404` (missing, or an
188208 /// authenticated stranger — no existence leak).
189209 #[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(
191211 state: &AppState,
192212 jar: &CookieJar,
193213 headers: &HeaderMap,
194214 owner: &str,
195215 repo_path: &str,
216+ required: auth::Access,
196217 ) -> Result<Repo, Response> {
197218 let owner_user = state.store.user_by_username(owner).await.ok().flatten();
198219 let repo = match owner_user {
@@ -229,7 +250,7 @@ async fn authorize(
229250 collaborator,
230251 state.allow_anonymous(),
231252 );
232- if access >= auth::Access::Read {
253+ if access >= required {
233254 Ok(repo)
234255 } else if viewer.is_none() {
235256 Err(unauthorized())
@@ -239,9 +260,14 @@ async fn authorize(
239260 }
240261 }
241262
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> {
245271 if let Some(cookie) = jar.get(&state.config.auth.cookie_name)
246272 && let Ok(Some(user)) = state
247273 .store
@@ -250,6 +276,9 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) ->
250276 {
251277 return Some(user);
252278 }
279+ if let Some(user) = bearer_user(state, headers).await {
280+ return Some(user);
281+ }
253282 let (username, password) = parse_basic(headers)?;
254283 let user = state
255284 .store
@@ -267,6 +296,23 @@ async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) ->
267296 }
268297 }
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`]).
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+
270316 /// Parse an HTTP Basic `Authorization` header into `(username, password)`.
271317 fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
272318 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;
2121 mod issues;
2222 mod languages;
2323 mod layout;
24+mod lfs;
2425 mod license;
2526 mod markdown;
2627 mod pages;
@@ -323,7 +324,7 @@ pub fn build_router(state: AppState) -> Router {
323324 .route("/{owner}", get(repo::user_page))
324325 .route(
325326 "/{owner}/{*rest}",
326- get(repo::dispatch).post(git_http::pack_rpc),
327+ get(repo::dispatch).post(git_http::pack_rpc).put(lfs::put),
327328 )
328329 .layer(
329330 ServiceBuilder::new()
crates/web/src/repo.rs +17 −0
@@ -341,6 +341,23 @@ pub async fn dispatch(
341341 Query(dq): Query<DiffQuery>,
342342 Path((owner_name, rest)): Path<(String, String)>,
343343 ) -> 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+
344361 // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob
345362 // path that merely contains ".git/" is never misrouted (those carry "/-/").
346363 if !rest.contains("/-/")
src/serve.rs +2 −1
@@ -61,8 +61,9 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> {
6161 let ssh_config = Arc::clone(&config);
6262 let ssh_store = store.clone();
6363 let ssh_config_path = req.config_path.as_ref().map(|p| p.display().to_string());
64+ let ssh_secret = secret.clone();
6465 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 {
6667 eprintln!("ssh server error: {err}");
6768 }
6869 });