Skip to content
Clone

feat(lib/git): expand lfs error and rework handle

ZhenYi committed
parent db4932e commit 4f48bfa Browse files

2 files changed

+82 −121

File tree 2
Modified lib/git/src/lfs/error.rs View lib/git/src/lfs/error.rs
@@ -12,6 +12,7 @@
1212pub fn http_status(err: &GitError) -> u16 {
1313 match err {
1414 GitError::NotFound => 404,
15 GitError::Unauthorized => 401,
1516 GitError::Forbidden => 403,
1617 GitError::Conflict(_) => 409,
1718 GitError::InvalidArgument(_) => 400,
@@ -26,6 +27,7 @@
2627 #[test]
2728 fn maps_variants_to_http_codes() {
2829 assert_eq!(http_status(&GitError::NotFound), 404);
30 assert_eq!(http_status(&GitError::Unauthorized), 401);
2931 assert_eq!(http_status(&GitError::Forbidden), 403);
3032 assert_eq!(http_status(&GitError::Conflict("dup".to_string())), 409);
3133 assert_eq!(
Modified lib/git/src/lfs/handle.rs View lib/git/src/lfs/handle.rs
@@ -4,14 +4,9 @@
44use axum::http::{HeaderMap, StatusCode, header};
55use axum::response::{IntoResponse, Json, Response};
66use axum::routing::{get, post, put};
7use base64::Engine as _;
87use futures::StreamExt as _;
98use http_body_util::BodyDataStream;
10use models::users::user;
11use models::users::user_access_keys::{self, AccessKeyScope};
12use sea_orm::EntityTrait;
139use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
1510use tokio_util::io::StreamReader;
1611use uuid::Uuid;
1712
@@ -21,6 +16,7 @@
2116};
2217use crate::lfs::{error, object};
2318use crate::service::GitService;
19use crate::share::access::{BasicAuth, allow_repo_read, require_repo_admin, require_writer};
2420use crate::share::errors::GitError;
2521
2622const DOCUMENTATION_URL: &str = "https://github.com/git-lfs/git-lfs/blob/main/docs/api/errors.md";
@@ -40,100 +36,6 @@
4036 .with_state(service)
4137}
4238
43pub struct BasicAuth {
44 pub token: Option<String>,
45}
46
47impl<S: Send + Sync> axum::extract::FromRequestParts<S> for BasicAuth {
48 type Rejection = LfsHttpError;
49
50 async fn from_request_parts(
51 parts: &mut axum::http::request::Parts,
52 _state: &S,
53 ) -> Result<Self, Self::Rejection> {
54 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
55 return Ok(BasicAuth { token: None });
56 };
57 let raw = value
58 .to_str()
59 .map_err(|_| LfsHttpError::bad_request("malformed authorization header"))?;
60 let Some(b64) = raw.strip_prefix("Basic ") else {
61 return Err(LfsHttpError::unauthorized());
62 };
63 let decoded = base64::engine::general_purpose::STANDARD
64 .decode(b64)
65 .map_err(|_| LfsHttpError::bad_request("malformed basic credentials"))?;
66 let creds = String::from_utf8(decoded)
67 .map_err(|_| LfsHttpError::bad_request("malformed basic credentials"))?;
68 let token = creds.split_once(':').map(|(_, pass)| pass.to_string());
69 Ok(BasicAuth { token })
70 }
71}
72
73pub async fn verify_token(
74 svc: &GitService,
75 token: &str,
76) -> Result<Option<(Uuid, AccessKeyScope)>, GitError> {
77 let Some((key_id, secret)) = token.split_once('.') else {
78 return Ok(None);
79 };
80 let Ok(key_id) = Uuid::parse_str(key_id) else {
81 return Ok(None);
82 };
83 let db = svc.state.database.clone();
84 let Some(row) = user_access_keys::Entity::find_by_id(key_id)
85 .one(db.read())
86 .await?
87 else {
88 return Ok(None);
89 };
90 let Some(caller) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
91 return Ok(None);
92 };
93 if !caller.is_active || caller.prohibit_login || caller.is_restricted {
94 return Ok(None);
95 }
96 let computed = sha256_hex(format!("{secret}{}", row.token_salt).as_bytes());
97 if computed.as_bytes() != row.token_hash.as_bytes() {
98 return Ok(None);
99 }
100 Ok(Some((row.user_id, row.scope)))
101}
102
103pub async fn require_writer(svc: &GitService, auth: &BasicAuth) -> Result<Uuid, LfsHttpError> {
104 let Some(token) = &auth.token else {
105 return Err(LfsHttpError::unauthorized());
106 };
107 let (uid, scope) = verify_token(svc, token)
108 .await
109 .map_err(LfsHttpError::from)?
110 .ok_or_else(LfsHttpError::unauthorized)?;
111 if !scope.allows(true) {
112 return Err(LfsHttpError::forbidden(
113 "access key does not allow write operations",
114 ));
115 }
116 Ok(uid)
117}
118
119pub async fn allow_read(
120 svc: &GitService,
121 auth: &BasicAuth,
122 repo_private: bool,
123) -> Result<Option<Uuid>, LfsHttpError> {
124 let Some(token) = &auth.token else {
125 if repo_private {
126 return Err(LfsHttpError::unauthorized());
127 }
128 return Ok(None);
129 };
130 let (uid, _) = verify_token(svc, token)
131 .await
132 .map_err(LfsHttpError::from)?
133 .ok_or_else(LfsHttpError::unauthorized)?;
134 Ok(Some(uid))
135}
136
13739pub async fn discovery(
13840 State(svc): State<GitService>,
13941 Path((space, repo_slug)): Path<(String, String)>,
@@ -166,15 +68,19 @@
16668 Json(req): Json<LfsBatchRequest>,
16769) -> Result<Json<LfsBatchResponse>, LfsHttpError> {
16870 let repo = skinny_repo_name(&repo_slug);
71 let ctx = svc
72 .context(space.clone(), repo.to_string())
73 .await
74 .map_err(LfsHttpError::from)?;
16975 let needs_write = matches!(req.operation, LfsOperation::Upload | LfsOperation::Verify);
17076 if needs_write {
171 require_writer(&svc, &auth).await?;
77 require_writer(&svc, &auth, &ctx)
78 .await
79 .map_err(LfsHttpError::from)?;
17280 } else {
173 let ctx = svc
174 .context(space.clone(), repo.to_string())
81 allow_repo_read(&svc, &auth, &ctx)
17582 .await
17683 .map_err(LfsHttpError::from)?;
177 allow_read(&svc, &auth, ctx.model.is_private).await?;
17884 }
17985 let resp = object::batch(&svc, space, repo.to_string(), req)
18086 .await
@@ -189,8 +95,14 @@
18995 headers: HeaderMap,
19096 body: Body,
19197) -> Result<StatusCode, LfsHttpError> {
192 require_writer(&svc, &auth).await?;
19398 let repo = skinny_repo_name(&repo_slug);
99 let ctx = svc
100 .context(space.clone(), repo.to_string())
101 .await
102 .map_err(LfsHttpError::from)?;
103 let uid = require_writer(&svc, &auth, &ctx)
104 .await
105 .map_err(LfsHttpError::from)?;
194106 let size = content_length(&headers).map_err(LfsHttpError::bad_request)?;
195107 let content_type = headers
196108 .get(header::CONTENT_TYPE)
@@ -205,7 +117,7 @@
205117 &svc,
206118 space,
207119 repo.to_string(),
208 Uuid::nil(),
120 uid,
209121 oid.clone(),
210122 size,
211123 content_type,
@@ -226,7 +138,9 @@
226138 .context(space.clone(), repo.to_string())
227139 .await
228140 .map_err(LfsHttpError::from)?;
229 allow_read(&svc, &auth, ctx.model.is_private).await?;
141 allow_repo_read(&svc, &auth, &ctx)
142 .await
143 .map_err(LfsHttpError::from)?;
230144 let oid = crate::lfs::canonical_oid(&oid)?;
231145 let key = format!("lfs/{}/{}", ctx.model.id.simple(), oid);
232146 let storage = &svc.state.storage;
@@ -247,8 +161,14 @@
247161 Path((space, repo_slug)): Path<(String, String)>,
248162 Json(req): Json<crate::lfs::types::LfsVerifyRequest>,
249163) -> Result<StatusCode, LfsHttpError> {
250 require_writer(&svc, &auth).await?;
251164 let repo = skinny_repo_name(&repo_slug);
165 let ctx = svc
166 .context(space.clone(), repo.to_string())
167 .await
168 .map_err(LfsHttpError::from)?;
169 require_writer(&svc, &auth, &ctx)
170 .await
171 .map_err(LfsHttpError::from)?;
252172 object::verify(&svc, space, repo.to_string(), req)
253173 .await
254174 .map_err(LfsHttpError::from)?;
@@ -266,7 +186,9 @@
266186 .context(space.clone(), repo.to_string())
267187 .await
268188 .map_err(LfsHttpError::from)?;
269 allow_read(&svc, &auth, ctx.model.is_private).await?;
189 allow_repo_read(&svc, &auth, &ctx)
190 .await
191 .map_err(LfsHttpError::from)?;
270192 let resp = crate::lfs::list_locks(&svc, space, repo.to_string(), q.cursor, q.limit)
271193 .await
272194 .map_err(LfsHttpError::from)?;
@@ -279,8 +201,14 @@
279201 Path((space, repo_slug)): Path<(String, String)>,
280202 Json(req): Json<LfsLockCreateRequest>,
281203) -> Result<(StatusCode, Json<LockResponse>), LfsHttpError> {
282 let uid = require_writer(&svc, &auth).await?;
283204 let repo = skinny_repo_name(&repo_slug);
205 let ctx = svc
206 .context(space.clone(), repo.to_string())
207 .await
208 .map_err(LfsHttpError::from)?;
209 let uid = require_writer(&svc, &auth, &ctx)
210 .await
211 .map_err(LfsHttpError::from)?;
284212 let lock = crate::lfs::create_lock(&svc, space, repo.to_string(), uid, req.path, None)
285213 .await
286214 .map_err(LfsHttpError::from)?;
@@ -302,7 +230,10 @@
302230 .context(space.clone(), skinny_repo_name(&repo_slug).to_string())
303231 .await
304232 .map_err(LfsHttpError::from)?;
305 let uid = match allow_read(&svc, &auth, ctx.model.is_private).await? {
233 let uid = match allow_repo_read(&svc, &auth, &ctx)
234 .await
235 .map_err(LfsHttpError::from)?
236 {
306237 Some(uid) => uid,
307238 None => {
308239 return Err(LfsHttpError::unauthorized());
@@ -326,8 +257,22 @@
326257 Path((space, repo_slug, lock_id)): Path<(String, String, String)>,
327258 Query(q): Query<UnlockQuery>,
328259) -> Result<Json<UnlockResponse>, LfsHttpError> {
329 let uid = require_writer(&svc, &auth).await?;
330260 let repo = skinny_repo_name(&repo_slug);
261 let ctx = svc
262 .context(space.clone(), repo.to_string())
263 .await
264 .map_err(LfsHttpError::from)?;
265 // `force=true` (per LFS spec) is reserved for repo admins — must be the
266 // space owner or an Admin collaborator, not an arbitrary Write token.
267 let uid = if q.force {
268 require_repo_admin(&svc, &auth, &ctx)
269 .await
270 .map_err(LfsHttpError::from)?
271 } else {
272 require_writer(&svc, &auth, &ctx)
273 .await
274 .map_err(LfsHttpError::from)?
275 };
331276 crate::lfs::unlock_by_id(&svc, space, repo.to_string(), uid, lock_id, q.force)
332277 .await
333278 .map_err(LfsHttpError::from)?;
@@ -338,7 +283,7 @@
338283
339284#[derive(Debug, Serialize)]
340285pub struct UnlockResponse {
341 message: String,
286 pub message: String,
342287}
343288
344289#[derive(Debug, Deserialize)]
@@ -367,11 +312,6 @@
367312 .ok_or("invalid Content-Length header")
368313}
369314
370fn sha256_hex(data: &[u8]) -> String {
371 let digest = Sha256::digest(data);
372 digest.iter().map(|b| format!("{b:02x}")).collect()
373}
374
375315#[derive(Debug, Serialize)]
376316struct LfsErrorBody {
377317 message: String,
@@ -410,7 +350,7 @@
410350}
411351
412352impl LfsHttpError {
413 fn unauthorized() -> Self {
353 pub fn unauthorized() -> Self {
414354 Self {
415355 response: challenge(),
416356 }
@@ -439,8 +379,27 @@
439379 fn from(err: GitError) -> Self {
440380 let status = StatusCode::from_u16(error::http_status(&err))
441381 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
442 Self {
443 response: status_json(status, &err.to_string()),
382 let message = match &err {
383 GitError::Unauthorized => "authentication required".to_string(),
384 _ => err.to_string(),
385 };
386 if status == StatusCode::UNAUTHORIZED {
387 Self {
388 response: (
389 StatusCode::UNAUTHORIZED,
390 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
391 Json(LfsErrorBody {
392 message,
393 request_id: Uuid::now_v7().simple().to_string(),
394 documentation_url: DOCUMENTATION_URL,
395 }),
396 )
397 .into_response(),
398 }
399 } else {
400 Self {
401 response: status_json(status, &message),
402 }
444403 }
445404 }
446}
405}
No newline at end of file