| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | use std::collections::HashMap; |
| 22 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 23 | |
| 24 | use axum::body::Body; |
| 25 | use axum::extract::{Path, State}; |
| 26 | use axum::http::{HeaderMap, StatusCode, header}; |
| 27 | use axum::response::{IntoResponse, Response}; |
| 28 | use axum_extra::extract::cookie::CookieJar; |
| 29 | use blob::Namespace; |
| 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 | |
| 40 | const LFS_JSON: &str = "application/vnd.git-lfs+json"; |
| 41 | |
| 42 | |
| 43 | static TMP_SEQ: AtomicU64 = AtomicU64::new(0); |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 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 | |
| 56 | |
| 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 | |
| 65 | |
| 66 | fn object_key(oid: &str) -> String { |
| 67 | format!("{}/{}/{}", &oid[0..2], &oid[2..4], oid) |
| 68 | } |
| 69 | |
| 70 | |
| 71 | fn hex(bytes: &[u8]) -> String { |
| 72 | let mut s = String::with_capacity(bytes.len() * 2); |
| 73 | for b in bytes { |
| 74 | s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0')); |
| 75 | s.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0')); |
| 76 | } |
| 77 | s |
| 78 | } |
| 79 | |
| 80 | |
| 81 | |
| 82 | pub(crate) async fn put( |
| 83 | State(state): State<AppState>, |
| 84 | jar: CookieJar, |
| 85 | headers: HeaderMap, |
| 86 | Path((owner, rest)): Path<(String, String)>, |
| 87 | body: Body, |
| 88 | ) -> Response { |
| 89 | let Some((repo_path, tail)) = split_lfs(&rest) else { |
| 90 | return StatusCode::NOT_FOUND.into_response(); |
| 91 | }; |
| 92 | let Some(oid) = tail.strip_prefix("objects/") else { |
| 93 | return StatusCode::NOT_FOUND.into_response(); |
| 94 | }; |
| 95 | if oid.contains('/') { |
| 96 | return StatusCode::NOT_FOUND.into_response(); |
| 97 | } |
| 98 | upload( |
| 99 | state, |
| 100 | jar, |
| 101 | headers, |
| 102 | owner, |
| 103 | repo_path.to_string(), |
| 104 | oid.to_string(), |
| 105 | body, |
| 106 | ) |
| 107 | .await |
| 108 | } |
| 109 | |
| 110 | |
| 111 | |
| 112 | |
| 113 | #[derive(Debug, Deserialize)] |
| 114 | struct ObjectId { |
| 115 | oid: String, |
| 116 | #[serde(default)] |
| 117 | size: i64, |
| 118 | } |
| 119 | |
| 120 | |
| 121 | #[derive(Debug, Deserialize)] |
| 122 | struct BatchRequest { |
| 123 | operation: String, |
| 124 | objects: Vec<ObjectId>, |
| 125 | } |
| 126 | |
| 127 | |
| 128 | #[derive(Debug, Serialize)] |
| 129 | struct Action { |
| 130 | href: String, |
| 131 | #[serde(skip_serializing_if = "Option::is_none")] |
| 132 | header: Option<HashMap<String, String>>, |
| 133 | } |
| 134 | |
| 135 | |
| 136 | #[derive(Debug, Default, Serialize)] |
| 137 | struct Actions { |
| 138 | #[serde(skip_serializing_if = "Option::is_none")] |
| 139 | download: Option<Action>, |
| 140 | #[serde(skip_serializing_if = "Option::is_none")] |
| 141 | upload: Option<Action>, |
| 142 | #[serde(skip_serializing_if = "Option::is_none")] |
| 143 | verify: Option<Action>, |
| 144 | } |
| 145 | |
| 146 | |
| 147 | #[derive(Debug, Serialize)] |
| 148 | struct ObjectError { |
| 149 | code: u16, |
| 150 | message: String, |
| 151 | } |
| 152 | |
| 153 | |
| 154 | #[derive(Debug, Serialize)] |
| 155 | struct BatchObject { |
| 156 | oid: String, |
| 157 | size: i64, |
| 158 | #[serde(skip_serializing_if = "Option::is_none")] |
| 159 | actions: Option<Actions>, |
| 160 | #[serde(skip_serializing_if = "Option::is_none")] |
| 161 | error: Option<ObjectError>, |
| 162 | } |
| 163 | |
| 164 | |
| 165 | #[derive(Debug, Serialize)] |
| 166 | struct BatchResponse { |
| 167 | transfer: String, |
| 168 | objects: Vec<BatchObject>, |
| 169 | } |
| 170 | |
| 171 | |
| 172 | |
| 173 | pub(crate) async fn batch( |
| 174 | state: AppState, |
| 175 | jar: CookieJar, |
| 176 | headers: HeaderMap, |
| 177 | owner: String, |
| 178 | repo_path: String, |
| 179 | body: Body, |
| 180 | ) -> Response { |
| 181 | let Ok(raw) = axum::body::to_bytes(body, 1024 * 1024).await else { |
| 182 | return lfs_error(StatusCode::BAD_REQUEST, "request too large"); |
| 183 | }; |
| 184 | let Ok(req) = serde_json::from_slice::<BatchRequest>(&raw) else { |
| 185 | return lfs_error(StatusCode::BAD_REQUEST, "malformed batch request"); |
| 186 | }; |
| 187 | let uploading = req.operation == "upload"; |
| 188 | let required = if uploading { |
| 189 | auth::Access::Write |
| 190 | } else { |
| 191 | auth::Access::Read |
| 192 | }; |
| 193 | let repo = match authorize(&state, &jar, &headers, &owner, &repo_path, required).await { |
| 194 | Ok(repo) => repo, |
| 195 | Err(resp) => return resp, |
| 196 | }; |
| 197 | |
| 198 | let base = format!( |
| 199 | "{}/{}/{}.git/info/lfs/objects", |
| 200 | state.config.instance.url.trim_end_matches('/'), |
| 201 | owner, |
| 202 | repo_path |
| 203 | ); |
| 204 | let mut objects = Vec::with_capacity(req.objects.len()); |
| 205 | for obj in &req.objects { |
| 206 | objects.push(if uploading { |
| 207 | batch_upload(&state, &repo.id, &base, obj).await |
| 208 | } else { |
| 209 | batch_download(&state, &repo.id, &base, obj).await |
| 210 | }); |
| 211 | } |
| 212 | let payload = BatchResponse { |
| 213 | transfer: "basic".to_string(), |
| 214 | objects, |
| 215 | }; |
| 216 | lfs_json(StatusCode::OK, &payload) |
| 217 | } |
| 218 | |
| 219 | |
| 220 | async fn batch_download( |
| 221 | state: &AppState, |
| 222 | repo_id: &str, |
| 223 | base: &str, |
| 224 | obj: &ObjectId, |
| 225 | ) -> BatchObject { |
| 226 | if valid_oid(&obj.oid) |
| 227 | && state |
| 228 | .store |
| 229 | .lfs_object_exists(repo_id, &obj.oid) |
| 230 | .await |
| 231 | .unwrap_or(false) |
| 232 | { |
| 233 | BatchObject { |
| 234 | oid: obj.oid.clone(), |
| 235 | size: obj.size, |
| 236 | actions: Some(Actions { |
| 237 | download: Some(Action { |
| 238 | href: format!("{base}/{}", obj.oid), |
| 239 | header: None, |
| 240 | }), |
| 241 | ..Actions::default() |
| 242 | }), |
| 243 | error: None, |
| 244 | } |
| 245 | } else { |
| 246 | BatchObject { |
| 247 | oid: obj.oid.clone(), |
| 248 | size: obj.size, |
| 249 | actions: None, |
| 250 | error: Some(ObjectError { |
| 251 | code: 404, |
| 252 | message: "object does not exist".to_string(), |
| 253 | }), |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | |
| 259 | async fn batch_upload(state: &AppState, repo_id: &str, base: &str, obj: &ObjectId) -> BatchObject { |
| 260 | if !valid_oid(&obj.oid) { |
| 261 | return BatchObject { |
| 262 | oid: obj.oid.clone(), |
| 263 | size: obj.size, |
| 264 | actions: None, |
| 265 | error: Some(ObjectError { |
| 266 | code: 422, |
| 267 | message: "invalid oid".to_string(), |
| 268 | }), |
| 269 | }; |
| 270 | } |
| 271 | |
| 272 | if state |
| 273 | .store |
| 274 | .lfs_object_exists(repo_id, &obj.oid) |
| 275 | .await |
| 276 | .unwrap_or(false) |
| 277 | { |
| 278 | return BatchObject { |
| 279 | oid: obj.oid.clone(), |
| 280 | size: obj.size, |
| 281 | actions: None, |
| 282 | error: None, |
| 283 | }; |
| 284 | } |
| 285 | BatchObject { |
| 286 | oid: obj.oid.clone(), |
| 287 | size: obj.size, |
| 288 | actions: Some(Actions { |
| 289 | upload: Some(Action { |
| 290 | href: format!("{base}/{}", obj.oid), |
| 291 | header: None, |
| 292 | }), |
| 293 | verify: Some(Action { |
| 294 | href: format!("{base}/{}/verify", obj.oid), |
| 295 | header: None, |
| 296 | }), |
| 297 | ..Actions::default() |
| 298 | }), |
| 299 | error: None, |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | |
| 304 | |
| 305 | |
| 306 | pub(crate) async fn download( |
| 307 | state: AppState, |
| 308 | jar: CookieJar, |
| 309 | headers: HeaderMap, |
| 310 | owner: String, |
| 311 | repo_path: String, |
| 312 | oid: String, |
| 313 | ) -> Response { |
| 314 | let repo = match authorize( |
| 315 | &state, |
| 316 | &jar, |
| 317 | &headers, |
| 318 | &owner, |
| 319 | &repo_path, |
| 320 | auth::Access::Read, |
| 321 | ) |
| 322 | .await |
| 323 | { |
| 324 | Ok(repo) => repo, |
| 325 | Err(resp) => return resp, |
| 326 | }; |
| 327 | if !valid_oid(&oid) |
| 328 | || !state |
| 329 | .store |
| 330 | .lfs_object_exists(&repo.id, &oid) |
| 331 | .await |
| 332 | .unwrap_or(false) |
| 333 | { |
| 334 | return StatusCode::NOT_FOUND.into_response(); |
| 335 | } |
| 336 | let Ok(Some(read)) = state.blobs.open(Namespace::Lfs, &object_key(&oid)).await else { |
| 337 | return StatusCode::NOT_FOUND.into_response(); |
| 338 | }; |
| 339 | let body = Body::from_stream(ReaderStream::new(read.reader)); |
| 340 | ( |
| 341 | [ |
| 342 | (header::CONTENT_TYPE, "application/octet-stream".to_string()), |
| 343 | (header::CONTENT_LENGTH, read.size.to_string()), |
| 344 | ], |
| 345 | body, |
| 346 | ) |
| 347 | .into_response() |
| 348 | } |
| 349 | |
| 350 | |
| 351 | |
| 352 | pub(crate) async fn upload( |
| 353 | state: AppState, |
| 354 | jar: CookieJar, |
| 355 | headers: HeaderMap, |
| 356 | owner: String, |
| 357 | repo_path: String, |
| 358 | oid: String, |
| 359 | body: Body, |
| 360 | ) -> Response { |
| 361 | let repo = match authorize( |
| 362 | &state, |
| 363 | &jar, |
| 364 | &headers, |
| 365 | &owner, |
| 366 | &repo_path, |
| 367 | auth::Access::Write, |
| 368 | ) |
| 369 | .await |
| 370 | { |
| 371 | Ok(repo) => repo, |
| 372 | Err(resp) => return resp, |
| 373 | }; |
| 374 | if !valid_oid(&oid) { |
| 375 | return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid oid"); |
| 376 | } |
| 377 | |
| 378 | if state |
| 379 | .store |
| 380 | .lfs_object_exists(&repo.id, &oid) |
| 381 | .await |
| 382 | .unwrap_or(false) |
| 383 | { |
| 384 | return StatusCode::OK.into_response(); |
| 385 | } |
| 386 | |
| 387 | |
| 388 | |
| 389 | let tmp_dir = state.config.storage.data_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 | "lfs-{}.{}.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 | |
| 409 | if state |
| 410 | .blobs |
| 411 | .put_file( |
| 412 | Namespace::Lfs, |
| 413 | &object_key(&oid), |
| 414 | &tmp, |
| 415 | "application/octet-stream", |
| 416 | ) |
| 417 | .await |
| 418 | .is_err() |
| 419 | { |
| 420 | let _ = tokio::fs::remove_file(&tmp).await; |
| 421 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); |
| 422 | } |
| 423 | if state |
| 424 | .store |
| 425 | .lfs_object_add(&repo.id, &oid, size) |
| 426 | .await |
| 427 | .is_err() |
| 428 | { |
| 429 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); |
| 430 | } |
| 431 | StatusCode::OK.into_response() |
| 432 | } |
| 433 | |
| 434 | |
| 435 | |
| 436 | pub(crate) async fn verify( |
| 437 | state: AppState, |
| 438 | jar: CookieJar, |
| 439 | headers: HeaderMap, |
| 440 | owner: String, |
| 441 | repo_path: String, |
| 442 | body: Body, |
| 443 | ) -> Response { |
| 444 | let repo = match authorize( |
| 445 | &state, |
| 446 | &jar, |
| 447 | &headers, |
| 448 | &owner, |
| 449 | &repo_path, |
| 450 | auth::Access::Read, |
| 451 | ) |
| 452 | .await |
| 453 | { |
| 454 | Ok(repo) => repo, |
| 455 | Err(resp) => return resp, |
| 456 | }; |
| 457 | let Ok(raw) = axum::body::to_bytes(body, 64 * 1024).await else { |
| 458 | return lfs_error(StatusCode::BAD_REQUEST, "request too large"); |
| 459 | }; |
| 460 | let Ok(obj) = serde_json::from_slice::<ObjectId>(&raw) else { |
| 461 | return lfs_error(StatusCode::BAD_REQUEST, "malformed request"); |
| 462 | }; |
| 463 | match state.store.lfs_object_size(&repo.id, &obj.oid).await { |
| 464 | Ok(Some(size)) if size == obj.size => StatusCode::OK.into_response(), |
| 465 | _ => lfs_error(StatusCode::NOT_FOUND, "object not found"), |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | |
| 470 | async fn stream_to_file(body: Body, path: &std::path::Path) -> std::io::Result<(String, i64)> { |
| 471 | let stream = body.into_data_stream().map_err(std::io::Error::other); |
| 472 | let reader = StreamReader::new(stream); |
| 473 | tokio::pin!(reader); |
| 474 | let mut file = tokio::fs::File::create(path).await?; |
| 475 | let mut hasher = Sha256::new(); |
| 476 | let mut buf = vec![0u8; 64 * 1024]; |
| 477 | let mut size: i64 = 0; |
| 478 | loop { |
| 479 | let n = reader.read(&mut buf).await?; |
| 480 | if n == 0 { |
| 481 | break; |
| 482 | } |
| 483 | hasher.update(&buf[..n]); |
| 484 | file.write_all(&buf[..n]).await?; |
| 485 | size += i64::try_from(n).unwrap_or(0); |
| 486 | } |
| 487 | file.flush().await?; |
| 488 | Ok((hex(&hasher.finalize()), size)) |
| 489 | } |
| 490 | |
| 491 | |
| 492 | fn lfs_json<T: Serialize>(status: StatusCode, value: &T) -> Response { |
| 493 | match serde_json::to_vec(value) { |
| 494 | Ok(bytes) => (status, [(header::CONTENT_TYPE, LFS_JSON)], bytes).into_response(), |
| 495 | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | |
| 500 | fn lfs_error(status: StatusCode, message: &str) -> Response { |
| 501 | let body = serde_json::json!({ "message": message }); |
| 502 | (status, [(header::CONTENT_TYPE, LFS_JSON)], body.to_string()).into_response() |
| 503 | } |
| 504 | |
| 505 | #[cfg(test)] |
| 506 | mod tests { |
| 507 | use super::*; |
| 508 | |
| 509 | #[test] |
| 510 | fn split_lfs_finds_the_repo_and_tail() { |
| 511 | assert_eq!( |
| 512 | split_lfs("owner-scope/repo.git/info/lfs/objects/batch"), |
| 513 | Some(("owner-scope/repo", "objects/batch")) |
| 514 | ); |
| 515 | |
| 516 | assert_eq!( |
| 517 | split_lfs("grp/sub/repo/info/lfs/objects/abc"), |
| 518 | Some(("grp/sub/repo", "objects/abc")) |
| 519 | ); |
| 520 | assert_eq!(split_lfs("owner/repo.git/info/refs"), None); |
| 521 | } |
| 522 | |
| 523 | #[test] |
| 524 | fn valid_oid_accepts_only_lowercase_hex_sha256() { |
| 525 | assert!(valid_oid(&"a".repeat(64))); |
| 526 | assert!(valid_oid(&"0123456789abcdef".repeat(4))); |
| 527 | assert!(!valid_oid(&"a".repeat(63)), "too short"); |
| 528 | assert!(!valid_oid(&"A".repeat(64)), "uppercase rejected"); |
| 529 | assert!(!valid_oid(&"g".repeat(64)), "non-hex rejected"); |
| 530 | } |
| 531 | |
| 532 | #[test] |
| 533 | fn hex_matches_a_known_sha256() { |
| 534 | |
| 535 | let digest = Sha256::digest(b""); |
| 536 | assert_eq!( |
| 537 | hex(&digest), |
| 538 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" |
| 539 | ); |
| 540 | } |
| 541 | } |