Skip to content
This repository was archived by the owner on Oct 19, 2026. It is now read-only.

Commit 3602c37

Browse files
authored
feat!: Rewrite the LFS-related API routes to align with the official documentation; configuring lfs.url is no longer required. (#1690)
1 parent 7649fe3 commit 3602c37

7 files changed

Lines changed: 181 additions & 69 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ libvault-core = "0.1.0"
3838
anyhow = "1.0.100"
3939
serde = { version = "1.0.228", features = ["derive"] }
4040
serde_json = "1.0.145"
41+
serde_urlencoded = "0.7"
4142
tracing = "0.1.41"
4243
tracing-subscriber = "0.3.20"
4344
tracing-appender = "0.2.3"

‎ceres/src/api_service/mono_api_service.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ impl ApiHandler for MonoApiService {
479479
.mono_storage()
480480
.get_tree_by_hash(hash)
481481
.await?
482-
.unwrap(),
482+
.unwrap_or_else(|| panic!("can't fetch tree by hash {}", hash)),
483483
))
484484
}
485485

@@ -489,7 +489,7 @@ impl ApiHandler for MonoApiService {
489489
.mono_storage()
490490
.get_commit_by_hash(hash)
491491
.await?
492-
.unwrap(),
492+
.unwrap_or_else(|| panic!("can't fetch commit by hash {}", hash)),
493493
))
494494
}
495495

‎jupiter/src/lfs_storage/mod.rs‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
use std::{
2-
path::{Path, PathBuf},
3-
sync::Arc,
4-
};
1+
use std::{path::Path, sync::Arc};
52

63
use async_trait::async_trait;
74
use aws_s3_storage::AwsS3Storage;
@@ -23,11 +20,7 @@ pub trait LfsFileStorage: Sync + Send {
2320
async fn get_object(&self, object_id: &str) -> Result<Bytes, MegaError>;
2421

2522
fn action_href(&self, object_id: &str, hostname: &str) -> String {
26-
let path = PathBuf::new()
27-
.join(hostname)
28-
.join("objects")
29-
.join(object_id);
30-
path.into_os_string().into_string().unwrap()
23+
format!("{}/info/lfs/objects/{}", hostname, object_id)
3124
}
3225

3326
async fn download_url(&self, object_id: &str, hostname: &str) -> Result<String, MegaError>;

‎mono/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ tracing-appender = { workspace = true }
3232
russh = { workspace = true }
3333
serde = { workspace = true, features = ["derive"] }
3434
serde_json = { workspace = true }
35+
serde_urlencoded = { workspace = true }
3536
chrono = { workspace = true }
3637
futures = { workspace = true }
3738
bytes = { workspace = true }

‎mono/src/api/mod.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::api::oauth::campsite_store::CampsiteApiStore;
1717
use ceres::{
1818
api_service::{
1919
ApiHandler, cache::GitObjectCache, import_api_service::ImportApiService,
20-
mono_api_service::MonoApiService,
20+
mono_api_service::MonoApiService, state::ProtocolApiState,
2121
},
2222
protocol::repo::Repo,
2323
};
@@ -103,6 +103,15 @@ impl From<&MonoApiServiceState> for MonoApiService {
103103
}
104104
}
105105

106+
impl FromRef<MonoApiServiceState> for ProtocolApiState {
107+
fn from_ref(state: &MonoApiServiceState) -> ProtocolApiState {
108+
ProtocolApiState {
109+
storage: state.storage.clone(),
110+
git_object_cache: state.git_object_cache.clone(),
111+
}
112+
}
113+
}
114+
106115
impl MonoApiServiceState {
107116
fn monorepo(&self) -> MonoApiService {
108117
self.into()

‎mono/src/api/router/lfs_router.rs‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,24 @@ const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";
6969

7070
/// The [LFS Server Discovery](https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md)
7171
/// document describes the server LFS discovery protocol.
72+
/// Example:
73+
/// Git remote: https://git-server.com/foo/bar
74+
/// LFS server: https://git-server.com/foo/bar.git/info/lfs
75+
/// Locks API: https://git-server.com/foo/bar.git/info/lfs/locks
7276
pub fn routers() -> OpenApiRouter<MonoApiServiceState> {
73-
OpenApiRouter::new()
74-
.route("/objects/{object_id}", get(lfs_download_object))
75-
.route("/objects/{object_id}/{chunk_id}", get(lfs_download_chunk))
76-
.route("/objects/{object_id}", put(lfs_upload_object))
77-
.route("/locks", get(list_locks))
78-
.route("/locks", post(create_lock))
79-
.route("/locks/verify", post(list_locks_for_verification))
80-
.route("/locks/{id}/unlock", post(delete_lock))
81-
.route("/objects/batch", post(lfs_process_batch))
82-
.route("/objects/{object_id}/chunks", get(lfs_fetch_chunk_ids))
77+
OpenApiRouter::new().nest(
78+
"/info/lfs",
79+
OpenApiRouter::new()
80+
.route("/objects/{object_id}", get(lfs_download_object))
81+
.route("/objects/{object_id}/{chunk_id}", get(lfs_download_chunk))
82+
.route("/objects/{object_id}", put(lfs_upload_object))
83+
.route("/locks", get(list_locks))
84+
.route("/locks", post(create_lock))
85+
.route("/locks/verify", post(list_locks_for_verification))
86+
.route("/locks/{id}/unlock", post(delete_lock))
87+
.route("/objects/batch", post(lfs_process_batch))
88+
.route("/objects/{object_id}/chunks", get(lfs_fetch_chunk_ids)),
89+
)
8390
}
8491

8592
pub async fn list_locks(

‎mono/src/server/http_server.rs‎

Lines changed: 148 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ use std::sync::Arc;
55

66
use anyhow::Result;
77
use axum::body::Body;
8-
use axum::extract::{Query, State};
8+
use axum::extract::FromRef;
99
use axum::http::{self, Request, Uri};
1010
use axum::response::Response;
11-
use axum::routing::get;
12-
use axum::{Router, middleware};
11+
use axum::routing::any;
12+
use axum::{Router, ServiceExt, middleware};
1313
use ceres::api_service::cache::GitObjectCache;
1414
use ceres::api_service::state::ProtocolApiState;
1515
use http::{HeaderValue, Method};
16-
use lazy_static::lazy_static;
17-
use regex::Regex;
16+
1817
use saturn::entitystore::EntityStore;
1918
use time::Duration;
19+
use tower::Layer;
2020
use tower::ServiceBuilder;
2121
use tower_http::cors::CorsLayer;
2222
use tower_http::decompression::RequestDecompressionLayer;
@@ -39,22 +39,24 @@ use crate::api::oauth::oauth_client;
3939
use crate::api::router::lfs_router;
4040
use context::AppContext;
4141

42-
pub fn remove_git_suffix(uri: Uri, git_suffix: &str) -> PathBuf {
43-
PathBuf::from(uri.path().replace(".git", "").replace(git_suffix, ""))
42+
pub fn remove_git_suffix(full_path: &str, git_suffix: &str) -> PathBuf {
43+
PathBuf::from(full_path.replace(".git", "").replace(git_suffix, ""))
4444
}
4545

4646
pub async fn start_http(ctx: AppContext, options: CommonHttpOptions) {
4747
let CommonHttpOptions { host, port } = options.clone();
4848

49+
let middleware = tower::util::MapRequestLayer::new(rewrite_lfs_request_uri::<Body>);
4950
let app = app(ctx, host.clone(), port).await;
51+
let app_with_middleware = middleware.layer(app);
5052

5153
let server_url = format!("{host}:{port}");
5254

5355
let addr = SocketAddr::from_str(&server_url).unwrap();
5456
tracing::info!("HTTP server started up!");
5557

5658
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
57-
axum::serve(listener, app.into_make_service())
59+
axum::serve(listener, app_with_middleware.into_make_service())
5860
.await
5961
.unwrap();
6062
}
@@ -97,10 +99,6 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router {
9799
connection: ctx.connection.clone(),
98100
prefix: "git-object-bincode".to_string(),
99101
});
100-
let state = ProtocolApiState {
101-
storage: storage.clone(),
102-
git_object_cache: git_object_cache.clone(),
103-
};
104102

105103
let api_state = MonoApiServiceState {
106104
storage: storage.clone(),
@@ -135,11 +133,22 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router {
135133
"/api/v1",
136134
api_router::routers()
137135
.with_state(api_state.clone())
138-
.route_layer(middleware::from_fn_with_state(api_state, cedar_guard)),
136+
.route_layer(middleware::from_fn_with_state(
137+
api_state.clone(),
138+
cedar_guard,
139+
)),
139140
)
140141
// .nest("/auth", oauth::routers().with_state(api_state.clone()))
141142
// Using Regular Expressions for Path Matching in Protocol
142-
.route("/{*path}", get(get_method_router).post(post_method_router))
143+
.route(
144+
"/{*path}",
145+
any({
146+
let api_state = api_state.clone();
147+
move |req: Request<Body>| {
148+
handle_smart_protocol(req, Arc::new(ProtocolApiState::from_ref(&api_state)))
149+
}
150+
}),
151+
)
143152
.layer(
144153
ServiceBuilder::new().layer(session_layer).layer(
145154
CorsLayer::new()
@@ -160,59 +169,76 @@ pub async fn app(ctx: AppContext, host: String, port: u16) -> Router {
160169
)
161170
.layer(TraceLayer::new_for_http())
162171
.layer(RequestDecompressionLayer::new())
163-
.with_state(state)
172+
.with_state(api_state)
164173
.split_for_parts();
165174
router.merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", api))
166175
}
167176

168-
lazy_static! {
169-
/// The following regular expressions are used to match the Git server protocol.
170-
static ref INFO_REFS_REGEX: Regex = Regex::new(r"/info/refs$").unwrap();
171-
static ref REGEX_GIT_UPLOAD_PACK: Regex = Regex::new(r"/git-upload-pack$").unwrap();
172-
static ref REGEX_GIT_RECEIVE_PACK: Regex = Regex::new(r"/git-receive-pack$").unwrap();
173-
}
177+
fn rewrite_lfs_request_uri<B>(mut req: Request<B>) -> Request<B> {
178+
let full_path = req.uri().path();
174179

175-
pub async fn get_method_router(
176-
state: State<ProtocolApiState>,
177-
Query(params): Query<InfoRefsParams>,
178-
uri: Uri,
179-
) -> Result<Response<Body>, ProtocolError> {
180-
if INFO_REFS_REGEX.is_match(uri.path()) {
181-
let pack_protocol = SmartProtocol::new(
182-
remove_git_suffix(uri, "/info/refs"),
183-
TransportProtocol::Http,
184-
);
185-
crate::git_protocol::http::git_info_refs(&state, params, pack_protocol).await
186-
} else {
187-
Err(ProtocolError::NotFound(
188-
"Operation not supported".to_owned(),
189-
))
180+
if let Some(pos) = full_path.rfind("/info/lfs/") {
181+
let lfs_subpath = &full_path[pos..];
182+
183+
let new_path_and_query = if let Some(query) = req.uri().query() {
184+
format!("{}?{}", lfs_subpath, query)
185+
} else {
186+
lfs_subpath.to_owned()
187+
};
188+
189+
let new_uri = match Uri::builder().path_and_query(&new_path_and_query).build() {
190+
Ok(uri) => uri,
191+
Err(e) => {
192+
tracing::warn!(
193+
"Failed to rewrite LFS URI: {}, error: {}",
194+
new_path_and_query,
195+
e
196+
);
197+
// Return the request unchanged, let downstream handlers deal with it
198+
return req;
199+
}
200+
};
201+
202+
tracing::debug!("rewrite: old uri {:?}", req.uri());
203+
*req.uri_mut() = new_uri;
204+
tracing::debug!("rewrite: new uri {:?}", req.uri());
190205
}
206+
req
191207
}
192208

193-
pub async fn post_method_router(
194-
state: State<ProtocolApiState>,
195-
uri: Uri,
209+
async fn handle_smart_protocol(
196210
req: Request<Body>,
211+
state: Arc<ProtocolApiState>,
197212
) -> Result<Response, ProtocolError> {
198-
if REGEX_GIT_UPLOAD_PACK.is_match(uri.path()) {
213+
let full_path = req.uri().path();
214+
if full_path.ends_with("/info/refs") && req.method().eq(&Method::GET) {
215+
let pack_protocol = SmartProtocol::new(
216+
remove_git_suffix(full_path, "/info/refs"),
217+
TransportProtocol::Http,
218+
);
219+
let uri = req.uri();
220+
let query_str = uri.query().unwrap_or("");
221+
let params: InfoRefsParams = serde_urlencoded::from_str(query_str).unwrap();
222+
crate::git_protocol::http::git_info_refs(&state, params, pack_protocol).await
223+
} else if full_path.ends_with("/git-upload-pack") && req.method().eq(&Method::POST) {
199224
let mut pack_protocol = SmartProtocol::new(
200-
remove_git_suffix(uri.clone(), "/git-upload-pack"),
225+
remove_git_suffix(full_path, "/git-upload-pack"),
201226
TransportProtocol::Http,
202227
);
203228
pack_protocol.service_type = Some(ServiceType::UploadPack);
204229
crate::git_protocol::http::git_upload_pack(&state, req, pack_protocol).await
205-
} else if REGEX_GIT_RECEIVE_PACK.is_match(uri.path()) {
230+
} else if full_path.ends_with("/git-receive-pack") && req.method().eq(&Method::POST) {
206231
let mut pack_protocol = SmartProtocol::new(
207-
remove_git_suffix(uri.clone(), "/git-receive-pack"),
232+
remove_git_suffix(full_path, "/git-receive-pack"),
208233
TransportProtocol::Http,
209234
);
210235
pack_protocol.service_type = Some(ServiceType::ReceivePack);
211236
crate::git_protocol::http::git_receive_pack(&state, req, pack_protocol).await
212237
} else {
213-
Err(ProtocolError::NotFound(
214-
"Operation not supported".to_owned(),
215-
))
238+
Ok(Response::builder()
239+
.status(404)
240+
.body(Body::from("Operation not supported"))
241+
.unwrap())
216242
}
217243
}
218244

@@ -247,4 +273,79 @@ pub const MERGE_QUEUE_TAG: &str = "Merge Queue Management";
247273
struct ApiDoc;
248274

249275
#[cfg(test)]
250-
mod test {}
276+
mod tests {
277+
use super::*;
278+
use http::Request;
279+
280+
#[test]
281+
fn test_rewrite_lfs_uri_basic() {
282+
let req = Request::builder()
283+
.uri("/repo/a/b/info/lfs/objects/123")
284+
.body(())
285+
.unwrap();
286+
287+
let new_req = rewrite_lfs_request_uri(req);
288+
289+
assert_eq!(new_req.uri().path(), "/info/lfs/objects/123");
290+
}
291+
292+
#[test]
293+
fn test_rewrite_keeps_query_string() {
294+
let req = Request::builder()
295+
.uri("/repo/a/info/lfs/locks?token=abc123")
296+
.body(())
297+
.unwrap();
298+
299+
let new_req = rewrite_lfs_request_uri(req);
300+
301+
assert_eq!(
302+
new_req.uri().path_and_query().unwrap().to_string(),
303+
"/info/lfs/locks?token=abc123"
304+
);
305+
}
306+
307+
#[test]
308+
fn test_no_rewrite_when_no_lfs_prefix() {
309+
let req = Request::builder().uri("/not-lfs-path").body(()).unwrap();
310+
311+
let new_req = rewrite_lfs_request_uri(req);
312+
313+
assert_eq!(new_req.uri().path(), "/not-lfs-path");
314+
}
315+
316+
#[test]
317+
fn test_rewrite_with_trailing_slash() {
318+
let req = Request::builder()
319+
.uri("/repo/info/lfs/locks/")
320+
.body(())
321+
.unwrap();
322+
323+
let new_req = rewrite_lfs_request_uri(req);
324+
325+
assert_eq!(new_req.uri().path(), "/info/lfs/locks/");
326+
}
327+
328+
#[test]
329+
fn test_rewrite_complex_path() {
330+
let req = Request::builder()
331+
.uri("/a/b/c/info/lfs/objects/abc/def/ghi")
332+
.body(())
333+
.unwrap();
334+
335+
let new_req = rewrite_lfs_request_uri(req);
336+
337+
assert_eq!(new_req.uri().path(), "/info/lfs/objects/abc/def/ghi");
338+
}
339+
340+
#[test]
341+
fn test_rewrite_when_repo_path_contains_info_lfs() {
342+
let req = Request::builder()
343+
.uri("/repos/info/lfs/info/lfs/objects/123")
344+
.body(())
345+
.unwrap();
346+
347+
let new_req = rewrite_lfs_request_uri(req);
348+
349+
assert_eq!(new_req.uri().path(), "/info/lfs/objects/123");
350+
}
351+
}

0 commit comments

Comments
 (0)