This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / src / reads.rs
55 kB 1715 lines
1use std::collections::BTreeMap; 2use std::io::{Seek, SeekFrom}; 3use std::sync::Arc; 4 5use axum::body::Body; 6use axum::extract::{Query, Request, State}; 7use axum::response::{IntoResponse, Response}; 8use http::{HeaderMap, HeaderValue, StatusCode, header}; 9use serde::de::{self, Deserializer}; 10use serde::{Deserialize, Serialize}; 11use sha2::{Digest, Sha256}; 12use tower::ServiceExt; 13use tower_http::services::ServeFile; 14 15use knot_git::{ 16 ArchiveFormat, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, SizedEntry, 17 is_public_ref, screens_reserved, 18}; 19use knot_index::{Coverage, Resolved}; 20use knot_runtime::{Clock, HttpTransport}; 21use knot_types::{AuthorName, Email, Oid, OwnerDid, RepoDid, RepoPath, RepoRkey}; 22 23use crate::error::XrpcError; 24use crate::patchtext::{render_format_patch, render_patches}; 25use crate::query::{ 26 BranchArg, Limit, Offset, Order, RawFlag, RepoArg, Revspec, TagArg, Total, TreePath, 27 ValidatedQuery, next_cursor, 28}; 29use crate::wire::{ 30 BranchWire, CommitWire, FileWire, FormatPatchWire, PatchIdentityWire, TagWire, ZERO_TIME, 31 fold_subject, message_body, nice_diff, normalize_message_section, rfc2822, rfc3339, 32}; 33use crate::{XrpcState, run_blocking, sniff}; 34 35pub(crate) const TREE_ROUTE: &str = "/xrpc/sh.tangled.repo.tree"; 36pub(crate) const LOG_ROUTE: &str = "/xrpc/sh.tangled.repo.log"; 37pub(crate) const BRANCHES_ROUTE: &str = "/xrpc/sh.tangled.repo.branches"; 38pub(crate) const BRANCH_ROUTE: &str = "/xrpc/sh.tangled.repo.branch"; 39pub(crate) const TAGS_ROUTE: &str = "/xrpc/sh.tangled.repo.tags"; 40pub(crate) const TAG_ROUTE: &str = "/xrpc/sh.tangled.repo.tag"; 41pub(crate) const BLOB_ROUTE: &str = "/xrpc/sh.tangled.repo.blob"; 42pub(crate) const DIFF_ROUTE: &str = "/xrpc/sh.tangled.repo.diff"; 43pub(crate) const COMPARE_ROUTE: &str = "/xrpc/sh.tangled.repo.compare"; 44pub(crate) const ARCHIVE_ROUTE: &str = "/xrpc/sh.tangled.repo.archive"; 45pub(crate) const LANGUAGES_ROUTE: &str = "/xrpc/sh.tangled.repo.languages"; 46pub(crate) const GET_DEFAULT_BRANCH_ROUTE: &str = "/xrpc/sh.tangled.repo.getDefaultBranch"; 47pub(crate) const DESCRIBE_REPO_ROUTE: &str = "/xrpc/sh.tangled.repo.describeRepo"; 48pub(crate) const LIST_REFS_ROUTE: &str = "/xrpc/sh.tangled.git.listRefs"; 49pub(crate) const LIST_REPOS_ROUTE: &str = "/xrpc/sh.tangled.sync.listRepos"; 50 51const DEFAULT_PAGE: usize = 50; 52const MAX_PAGE: usize = 100; 53const LIST_REFS_DEFAULT: usize = 100; 54const LIST_REFS_MAX: usize = 1000; 55const LIST_REPOS_DEFAULT: usize = 50; 56const LIST_REPOS_MAX: usize = 1000; 57const MAX_BLOB_BYTES: u64 = 25 * 1024 * 1024; 58const MAX_COMPARE_COMMITS: usize = 500; 59const ARCHIVE_CAP_MESSAGE: &str = "archive exceeds configured maximum size"; 60const RAW_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; 61 62pub(crate) fn repo_not_found() -> XrpcError { 63 XrpcError::named( 64 StatusCode::NOT_FOUND, 65 "RepoNotFound", 66 "repository not found on this knot", 67 ) 68} 69 70fn ref_not_found() -> XrpcError { 71 XrpcError::named( 72 StatusCode::NOT_FOUND, 73 "RefNotFound", 74 "git reference not found", 75 ) 76} 77 78fn blob_too_large() -> XrpcError { 79 XrpcError::named( 80 StatusCode::PAYLOAD_TOO_LARGE, 81 "BlobTooLarge", 82 "file is too large to serve", 83 ) 84} 85 86fn blob_serving_limit(raw: bool, response_limit: usize) -> u64 { 87 match raw { 88 true => MAX_BLOB_BYTES, 89 false => MAX_BLOB_BYTES.min(response_limit as u64 / 4 * 3), 90 } 91} 92 93fn readme_serving_limit(response_limit: usize) -> u64 { 94 response_limit as u64 / 8 95} 96 97fn names_reserved(refspec: &str) -> bool { 98 screens_reserved(refspec) || screens_reserved(&format!("refs/{refspec}")) 99} 100 101pub(crate) fn warming() -> XrpcError { 102 XrpcError::warming("registry projection is still warming") 103} 104 105fn resolve_repo<H: HttpTransport, C: Clock>( 106 state: &XrpcState<H, C>, 107 repo: &RepoArg, 108) -> Result<RepoDid, XrpcError> { 109 match repo { 110 RepoArg::Did(did) => match state.index.owner_of(did) { 111 Resolved::Ready(Some(_)) => Ok(did.clone()), 112 Resolved::Ready(None) => Err(repo_not_found()), 113 Resolved::Warming => Err(warming()), 114 }, 115 RepoArg::OwnerRkey { owner, rkey } => match state.index.resolve_repo(owner, rkey) { 116 Resolved::Ready(Some(did)) => Ok(did), 117 Resolved::Ready(None) => Err(repo_not_found()), 118 Resolved::Warming => Err(warming()), 119 }, 120 } 121} 122 123pub(crate) fn open(layout: &Layout, did: &RepoDid) -> Result<Repo, XrpcError> { 124 layout 125 .open(did) 126 .map_err(|error| XrpcError::internal(format!("cannot open repository: {error}"))) 127} 128 129fn commit_for(repo: &Repo, refspec: &Revspec) -> Result<Oid, XrpcError> { 130 let refspec = refspec.as_str(); 131 if names_reserved(refspec) { 132 return Err(ref_not_found()); 133 } 134 let oid = match refspec.is_empty() { 135 true => repo.head().map(|head| head.target), 136 false => repo.resolve_revision(refspec), 137 } 138 .ok_or_else(ref_not_found)?; 139 let commit = repo.peel_to_commit(oid).map_err(|_| ref_not_found())?; 140 match repo.reachable_from_public(commit) { 141 Ok(true) => Ok(commit), 142 Ok(false) => Err(ref_not_found()), 143 Err(error) => Err(error.into()), 144 } 145} 146 147struct LimitWriter { 148 buf: Vec<u8>, 149 limit: usize, 150} 151 152impl std::io::Write for LimitWriter { 153 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> { 154 if self.buf.len() + data.len() > self.limit { 155 return Err(std::io::Error::new( 156 std::io::ErrorKind::WriteZero, 157 "response exceeds configured maximum size", 158 )); 159 } 160 self.buf.extend_from_slice(data); 161 Ok(data.len()) 162 } 163 164 fn flush(&mut self) -> std::io::Result<()> { 165 Ok(()) 166 } 167} 168 169fn json(value: impl Serialize, limit: usize) -> Result<Response, XrpcError> { 170 let mut writer = LimitWriter { 171 buf: Vec::new(), 172 limit, 173 }; 174 match serde_json::to_writer(&mut writer, &value) { 175 Ok(()) => Ok(( 176 StatusCode::OK, 177 [(header::CONTENT_TYPE, "application/json")], 178 writer.buf, 179 ) 180 .into_response()), 181 Err(error) if error.is_io() => Err(XrpcError::request_too_large( 182 "response exceeds configured maximum size", 183 )), 184 Err(error) => Err(XrpcError::internal(format!( 185 "failed to serialize response: {error}" 186 ))), 187 } 188} 189 190#[derive(Deserialize)] 191pub(crate) struct TreeParams { 192 repo: RepoArg, 193 #[serde(rename = "ref", default)] 194 refspec: Revspec, 195 #[serde(default)] 196 path: TreePath, 197} 198 199#[derive(Serialize)] 200struct SignatureOut { 201 name: AuthorName, 202 email: Email, 203 when: String, 204} 205 206#[derive(Serialize)] 207struct LastCommitOut { 208 hash: Oid, 209 message: String, 210 when: String, 211 #[serde(skip_serializing_if = "Option::is_none")] 212 author: Option<SignatureOut>, 213} 214 215#[derive(Serialize)] 216struct TreeEntryOut { 217 name: String, 218 mode: String, 219 size: i64, 220 #[serde(skip_serializing_if = "Option::is_none")] 221 last_commit: Option<LastCommitOut>, 222} 223 224#[derive(Serialize)] 225struct ReadmeOut { 226 filename: String, 227 contents: String, 228} 229 230#[derive(Serialize)] 231struct TreeOut { 232 #[serde(rename = "ref")] 233 refspec: String, 234 #[serde(skip_serializing_if = "Option::is_none")] 235 parent: Option<String>, 236 #[serde(skip_serializing_if = "Option::is_none")] 237 dotdot: Option<String>, 238 files: Vec<TreeEntryOut>, 239 #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] 240 last_commit: Option<LastCommitOut>, 241 readme: ReadmeOut, 242} 243 244fn is_readme(entry: &SizedEntry) -> bool { 245 let lower = entry.name.to_ascii_lowercase(); 246 entry.kind.is_file() 247 && (lower == "readme" 248 || lower 249 .strip_prefix("readme.") 250 .is_some_and(|extension| !extension.is_empty() && !extension.contains('.'))) 251} 252 253fn readme_of( 254 repo: &Repo, 255 commit: Oid, 256 dir: Option<&RepoPath>, 257 entries: &[SizedEntry], 258 response_limit: usize, 259) -> ReadmeOut { 260 entries 261 .iter() 262 .filter(|entry| is_readme(entry)) 263 .find_map(|entry| { 264 let path = match dir { 265 None => RepoPath::new(entry.name.as_str()).ok()?, 266 Some(dir) => RepoPath::new(format!("{dir}/{}", entry.name)).ok()?, 267 }; 268 let target = repo.entry_at(commit, &path).ok().flatten()?; 269 if repo.blob_size(target.oid).ok()? > readme_serving_limit(response_limit) { 270 return None; 271 } 272 let contents = repo.read_blob(target.oid).ok()?; 273 String::from_utf8(contents).ok().map(|contents| ReadmeOut { 274 filename: entry.name.clone(), 275 contents, 276 }) 277 }) 278 .unwrap_or(ReadmeOut { 279 filename: String::new(), 280 contents: String::new(), 281 }) 282} 283 284pub(crate) async fn repo_tree<H: HttpTransport, C: Clock>( 285 State(state): State<Arc<XrpcState<H, C>>>, 286 ValidatedQuery(params): ValidatedQuery<TreeParams>, 287) -> Result<Response, XrpcError> { 288 let did = resolve_repo(&state, &params.repo)?; 289 let layout = state.layout.clone(); 290 let limit = state.byte_limits.response.get(); 291 let tree_deadline = state.budgets.tree_last_commit.get().deadline(); 292 run_blocking(move || { 293 let repo = open(&layout, &did)?; 294 let commit = commit_for(&repo, &params.refspec)?; 295 let path_not_found = || { 296 XrpcError::named( 297 StatusCode::NOT_FOUND, 298 "PathNotFound", 299 "path not found in repository tree", 300 ) 301 }; 302 let dir = params.path.dir().ok_or_else(path_not_found)?; 303 let path = params.path.as_str(); 304 let entries = repo 305 .tree_entries_at(commit, dir)? 306 .ok_or_else(path_not_found)?; 307 let names: Vec<String> = entries.iter().map(|entry| entry.name.clone()).collect(); 308 let attributed = repo 309 .last_commits(commit, dir, &names, tree_deadline) 310 .unwrap_or_default(); 311 let files: Vec<TreeEntryOut> = entries 312 .iter() 313 .map(|entry| TreeEntryOut { 314 name: entry.name.clone(), 315 mode: entry.kind.mode_octal().to_string(), 316 size: entry.size as i64, 317 last_commit: attributed.get(&entry.name).map(|last| LastCommitOut { 318 hash: last.id, 319 message: last.subject.clone(), 320 when: rfc3339(last.time.get(), 0), 321 author: None, 322 }), 323 }) 324 .collect(); 325 let newest = attributed.values().max_by_key(|last| (last.time, last.id)); 326 let last_commit = newest.map(|last| LastCommitOut { 327 hash: last.id, 328 message: last.subject.clone(), 329 when: rfc3339(last.time.get(), 0), 330 author: repo.find_commit(last.id).ok().map(|commit| SignatureOut { 331 name: commit.author.name, 332 email: commit.author.email, 333 when: String::new(), 334 }), 335 }); 336 let readme = readme_of(&repo, commit, dir, &entries, limit); 337 let parent = (!path.is_empty()).then(|| path.to_string()); 338 let dotdot = (!path.is_empty()) 339 .then(|| path.rsplit_once('/').map(|(parent, _)| parent.to_string())) 340 .flatten(); 341 json( 342 TreeOut { 343 refspec: params.refspec.as_str().to_string(), 344 parent, 345 dotdot, 346 files, 347 last_commit, 348 readme, 349 }, 350 limit, 351 ) 352 }) 353 .await 354} 355 356#[derive(Deserialize)] 357pub(crate) struct LogParams { 358 repo: RepoArg, 359 #[serde(rename = "ref", default)] 360 refspec: Revspec, 361 #[serde(default)] 362 path: TreePath, 363 #[serde(default)] 364 limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 365 #[serde(default)] 366 cursor: Offset, 367} 368 369#[derive(Serialize)] 370struct LogOut { 371 #[serde(skip_serializing_if = "Vec::is_empty")] 372 commits: Vec<CommitWire>, 373 #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] 374 refspec: String, 375 #[serde(skip_serializing_if = "String::is_empty")] 376 description: String, 377 log: bool, 378 #[serde(skip_serializing_if = "is_zero")] 379 total: usize, 380 page: usize, 381 per_page: usize, 382} 383 384fn is_zero(value: &usize) -> bool { 385 *value == 0 386} 387 388pub(crate) async fn repo_log<H: HttpTransport, C: Clock>( 389 State(state): State<Arc<XrpcState<H, C>>>, 390 ValidatedQuery(params): ValidatedQuery<LogParams>, 391) -> Result<Response, XrpcError> { 392 let did = resolve_repo(&state, &params.repo)?; 393 let offset = params.cursor.get(); 394 let limit = params.limit.get(); 395 let layout = state.layout.clone(); 396 let response_limit = state.byte_limits.response.get(); 397 run_blocking(move || { 398 let repo = open(&layout, &did)?; 399 let start = commit_for(&repo, &params.refspec)?; 400 let (commits, total) = 401 repo.log_window(start, LogSkip::new(offset), LogLimit::new(limit))?; 402 json( 403 LogOut { 404 commits: commits.iter().map(CommitWire::of).collect(), 405 refspec: params.refspec.as_str().to_string(), 406 description: params.path.as_str().to_string(), 407 log: true, 408 total, 409 page: (offset / limit) + 1, 410 per_page: limit, 411 }, 412 response_limit, 413 ) 414 }) 415 .await 416} 417 418#[derive(Deserialize)] 419pub(crate) struct BranchesParams { 420 repo: RepoArg, 421 #[serde(default)] 422 limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 423 #[serde(default)] 424 cursor: Offset, 425} 426 427#[derive(Serialize)] 428struct BranchesOut { 429 #[serde(skip_serializing_if = "Vec::is_empty")] 430 branches: Vec<BranchWire>, 431} 432 433pub(crate) async fn repo_branches<H: HttpTransport, C: Clock>( 434 State(state): State<Arc<XrpcState<H, C>>>, 435 ValidatedQuery(params): ValidatedQuery<BranchesParams>, 436) -> Result<Response, XrpcError> { 437 let did = resolve_repo(&state, &params.repo)?; 438 let offset = params.cursor.get(); 439 let limit = params.limit.get(); 440 let layout = state.layout.clone(); 441 let response_limit = state.byte_limits.response.get(); 442 run_blocking(move || { 443 let repo = open(&layout, &did)?; 444 let mut branches = repo.branch_list()?; 445 branches.sort_by(|a, b| { 446 b.tip 447 .created_at() 448 .cmp(&a.tip.created_at()) 449 .then_with(|| a.name.cmp(&b.name)) 450 }); 451 let default = repo 452 .default_branch() 453 .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()); 454 let absent = repo.object_format().null_oid(); 455 let window: Vec<BranchWire> = branches 456 .iter() 457 .skip(offset) 458 .take(limit) 459 .map(|branch| { 460 BranchWire::of( 461 branch, 462 default.as_deref() == Some(branch.name.as_str()), 463 absent, 464 ) 465 }) 466 .rev() 467 .collect(); 468 json(BranchesOut { branches: window }, response_limit) 469 }) 470 .await 471} 472 473#[derive(Deserialize)] 474pub(crate) struct BranchParams { 475 repo: RepoArg, 476 #[serde(default)] 477 name: BranchArg, 478} 479 480#[derive(Serialize)] 481struct BranchOut { 482 name: String, 483 hash: String, 484 #[serde(rename = "shortHash")] 485 short_hash: String, 486 when: String, 487 #[serde(skip_serializing_if = "Option::is_none")] 488 message: Option<String>, 489 author: SignatureOut, 490 #[serde(rename = "isDefault")] 491 is_default: bool, 492} 493 494pub(crate) async fn repo_branch<H: HttpTransport, C: Clock>( 495 State(state): State<Arc<XrpcState<H, C>>>, 496 ValidatedQuery(params): ValidatedQuery<BranchParams>, 497) -> Result<Response, XrpcError> { 498 let did = resolve_repo(&state, &params.repo)?; 499 let Some(name) = params.name.get().cloned() else { 500 return Err(XrpcError::invalid_request("missing name parameter")); 501 }; 502 let layout = state.layout.clone(); 503 let limit = state.byte_limits.response.get(); 504 run_blocking(move || { 505 let repo = open(&layout, &did)?; 506 let branch_not_found = 507 || XrpcError::named(StatusCode::NOT_FOUND, "BranchNotFound", "branch not found"); 508 let target = repo 509 .find_ref(&name.head_ref()) 510 .ok() 511 .flatten() 512 .ok_or_else(branch_not_found)?; 513 let commit = repo.find_commit(target).map_err(|_| branch_not_found())?; 514 let default = repo 515 .default_branch() 516 .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()); 517 let hash = target.to_hex(); 518 json( 519 BranchOut { 520 name: name.to_string(), 521 short_hash: hash[..7].to_string(), 522 hash, 523 when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), 524 message: (!commit.message.is_empty()).then(|| commit.message.clone()), 525 author: SignatureOut { 526 name: commit.author.name.clone(), 527 email: commit.author.email.clone(), 528 when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), 529 }, 530 is_default: default.as_deref() == Some(name.as_str()), 531 }, 532 limit, 533 ) 534 }) 535 .await 536} 537 538#[derive(Deserialize)] 539pub(crate) struct TagsParams { 540 repo: RepoArg, 541 #[serde(default)] 542 limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 543 #[serde(default)] 544 cursor: Offset, 545} 546 547#[derive(Serialize)] 548struct TagsOut { 549 #[serde(skip_serializing_if = "Vec::is_empty")] 550 tags: Vec<TagWire>, 551} 552 553pub(crate) async fn repo_tags<H: HttpTransport, C: Clock>( 554 State(state): State<Arc<XrpcState<H, C>>>, 555 ValidatedQuery(params): ValidatedQuery<TagsParams>, 556) -> Result<Response, XrpcError> { 557 let did = resolve_repo(&state, &params.repo)?; 558 let offset = params.cursor.get(); 559 let limit = params.limit.get(); 560 let layout = state.layout.clone(); 561 let response_limit = state.byte_limits.response.get(); 562 run_blocking(move || { 563 let repo = open(&layout, &did)?; 564 let mut tags = repo.tag_list()?; 565 tags.sort_by(|a, b| { 566 b.created_at 567 .cmp(&a.created_at) 568 .then_with(|| a.name.cmp(&b.name)) 569 }); 570 let window: Vec<TagWire> = tags 571 .iter() 572 .skip(offset) 573 .take(limit) 574 .map(TagWire::of) 575 .collect(); 576 json(TagsOut { tags: window }, response_limit) 577 }) 578 .await 579} 580 581#[derive(Deserialize)] 582pub(crate) struct TagParams { 583 repo: RepoArg, 584 #[serde(default)] 585 tag: TagArg, 586} 587 588#[derive(Serialize)] 589struct TagOut { 590 tag: TagWire, 591} 592 593pub(crate) async fn repo_tag<H: HttpTransport, C: Clock>( 594 State(state): State<Arc<XrpcState<H, C>>>, 595 ValidatedQuery(params): ValidatedQuery<TagParams>, 596) -> Result<Response, XrpcError> { 597 let did = resolve_repo(&state, &params.repo)?; 598 let Some(name) = params.tag.get().cloned() else { 599 return Err(XrpcError::invalid_request("missing tag parameter")); 600 }; 601 let layout = state.layout.clone(); 602 let limit = state.byte_limits.response.get(); 603 run_blocking(move || { 604 let repo = open(&layout, &did)?; 605 let info = repo 606 .tag_list()? 607 .into_iter() 608 .find(|tag| tag.name == name) 609 .ok_or_else(|| { 610 XrpcError::named(StatusCode::BAD_REQUEST, "TagNotFound", "tag not found") 611 })?; 612 json( 613 TagOut { 614 tag: TagWire::of(&info), 615 }, 616 limit, 617 ) 618 }) 619 .await 620} 621 622#[derive(Deserialize)] 623pub(crate) struct BlobParams { 624 repo: RepoArg, 625 #[serde(rename = "ref", default)] 626 refspec: Revspec, 627 #[serde(default)] 628 path: TreePath, 629 #[serde(default)] 630 raw: RawFlag, 631} 632 633#[derive(Serialize)] 634struct SubmoduleOut { 635 name: String, 636 url: String, 637 branch: String, 638} 639 640#[derive(Serialize)] 641struct BlobOut { 642 #[serde(rename = "ref")] 643 refspec: String, 644 path: String, 645 #[serde(skip_serializing_if = "Option::is_none")] 646 content: Option<String>, 647 #[serde(skip_serializing_if = "Option::is_none")] 648 encoding: Option<&'static str>, 649 #[serde(skip_serializing_if = "Option::is_none")] 650 size: Option<i64>, 651 #[serde(rename = "isBinary", skip_serializing_if = "Option::is_none")] 652 is_binary: Option<bool>, 653 #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] 654 mime_type: Option<&'static str>, 655 #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] 656 last_commit: Option<LastCommitOut>, 657 #[serde(skip_serializing_if = "Option::is_none")] 658 submodule: Option<SubmoduleOut>, 659} 660 661fn etag_matches(headers: &HeaderMap, etag: &str) -> bool { 662 headers 663 .get_all(header::IF_NONE_MATCH) 664 .iter() 665 .filter_map(|value| value.to_str().ok()) 666 .flat_map(|value| value.split(',')) 667 .map(str::trim) 668 .any(|candidate| { 669 candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag 670 }) 671} 672 673fn quoted_etag(digest: &[u8]) -> String { 674 format!("\"{}\"", knot_types::lowercase_hex(digest)) 675} 676 677fn serve_raw( 678 headers: &HeaderMap, 679 mime: &'static str, 680 contents: Vec<u8>, 681) -> Result<Response, XrpcError> { 682 if mime.starts_with("image/") || mime.starts_with("video/") { 683 let etag = quoted_etag(&Sha256::digest(&contents)); 684 if etag_matches(headers, &etag) { 685 return Ok(StatusCode::NOT_MODIFIED.into_response()); 686 } 687 return Ok(( 688 StatusCode::OK, 689 [ 690 (header::ETAG, etag), 691 (header::CONTENT_TYPE, mime.to_string()), 692 (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), 693 (header::CONTENT_SECURITY_POLICY, RAW_CSP.to_string()), 694 ], 695 contents, 696 ) 697 .into_response()); 698 } 699 if sniff::is_textual_mime(mime) { 700 return Ok(( 701 StatusCode::OK, 702 [ 703 (header::CACHE_CONTROL, "public, no-cache".to_string()), 704 ( 705 header::CONTENT_TYPE, 706 "text/plain; charset=utf-8".to_string(), 707 ), 708 (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), 709 (header::CONTENT_SECURITY_POLICY, RAW_CSP.to_string()), 710 ], 711 contents, 712 ) 713 .into_response()); 714 } 715 Err(XrpcError::named( 716 StatusCode::FORBIDDEN, 717 "InvalidRequest", 718 "only image, video, and text files can be accessed directly", 719 )) 720} 721 722pub(crate) async fn repo_blob<H: HttpTransport, C: Clock>( 723 State(state): State<Arc<XrpcState<H, C>>>, 724 ValidatedQuery(params): ValidatedQuery<BlobParams>, 725 headers: HeaderMap, 726) -> Result<Response, XrpcError> { 727 let did = resolve_repo(&state, &params.repo)?; 728 if params.path.as_str().is_empty() { 729 return Err(XrpcError::invalid_request("missing path parameter")); 730 } 731 let layout = state.layout.clone(); 732 let limit = state.byte_limits.response.get(); 733 let blob_deadline = state.budgets.blob_last_commit.get().deadline(); 734 run_blocking(move || { 735 let refspec = params.refspec.as_str().to_string(); 736 let path = params.path.as_str().to_string(); 737 let raw = params.raw.requested(); 738 let repo = open(&layout, &did)?; 739 let commit = commit_for(&repo, &params.refspec)?; 740 let submodule = repo 741 .submodules(commit) 742 .unwrap_or_default() 743 .into_iter() 744 .find(|submodule| submodule.path.as_str() == path); 745 if let Some(submodule) = submodule { 746 return json( 747 BlobOut { 748 refspec, 749 path, 750 content: None, 751 encoding: None, 752 size: None, 753 is_binary: None, 754 mime_type: None, 755 last_commit: None, 756 submodule: Some(SubmoduleOut { 757 name: submodule.name, 758 url: submodule.url, 759 branch: submodule 760 .branch 761 .map(|branch| branch.to_string()) 762 .unwrap_or_default(), 763 }), 764 }, 765 limit, 766 ); 767 } 768 let file_not_found = || { 769 XrpcError::named( 770 StatusCode::NOT_FOUND, 771 "FileNotFound", 772 "file not found at specified path", 773 ) 774 }; 775 let file_path = params.path.file().ok_or_else(file_not_found)?; 776 let entry = repo 777 .entry_at(commit, file_path)? 778 .filter(|entry| { 779 matches!( 780 entry.kind, 781 EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link 782 ) 783 }) 784 .ok_or_else(file_not_found)?; 785 if repo.blob_size(entry.oid).map_err(|_| file_not_found())? > blob_serving_limit(raw, limit) 786 { 787 return Err(blob_too_large()); 788 } 789 let contents = repo.read_blob(entry.oid).map_err(|_| file_not_found())?; 790 let mime = sniff::override_by_extension(&path, sniff::detect_content_type(&contents)); 791 792 if raw { 793 return serve_raw(&headers, mime, contents); 794 } 795 796 let is_binary = !sniff::is_textual_mime(mime); 797 let size = contents.len() as i64; 798 let (content, encoding) = match is_binary { 799 true => ( 800 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &contents), 801 "base64", 802 ), 803 false => (String::from_utf8_lossy(&contents).into_owned(), "utf-8"), 804 }; 805 let dir = file_path.parent(); 806 let name = file_path.file_name().to_string(); 807 let last_commit = repo 808 .last_commits( 809 commit, 810 dir.as_ref(), 811 std::slice::from_ref(&name), 812 blob_deadline, 813 ) 814 .ok() 815 .and_then(|attributed| attributed.get(&name).cloned()) 816 .map(|last| LastCommitOut { 817 hash: last.id, 818 message: last.subject, 819 when: rfc3339(last.time.get(), 0), 820 author: repo.find_commit(last.id).ok().map(|commit| SignatureOut { 821 name: commit.author.name, 822 email: commit.author.email, 823 when: String::new(), 824 }), 825 }); 826 json( 827 BlobOut { 828 refspec, 829 path, 830 content: Some(content), 831 encoding: Some(encoding), 832 size: Some(size), 833 is_binary: Some(is_binary), 834 mime_type: Some(mime), 835 last_commit, 836 submodule: None, 837 }, 838 limit, 839 ) 840 }) 841 .await 842} 843 844#[derive(Deserialize)] 845pub(crate) struct DiffParams { 846 repo: RepoArg, 847 #[serde(rename = "ref", default)] 848 refspec: Revspec, 849} 850 851#[derive(Serialize)] 852struct DiffOut { 853 #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] 854 refspec: String, 855 diff: crate::wire::NiceDiffWire, 856} 857 858pub(crate) async fn repo_diff<H: HttpTransport, C: Clock>( 859 State(state): State<Arc<XrpcState<H, C>>>, 860 ValidatedQuery(params): ValidatedQuery<DiffParams>, 861) -> Result<Response, XrpcError> { 862 let did = resolve_repo(&state, &params.repo)?; 863 let layout = state.layout.clone(); 864 let limit = state.byte_limits.response.get(); 865 run_blocking(move || { 866 let repo = open(&layout, &did)?; 867 let target = commit_for(&repo, &params.refspec)?; 868 let commit = repo.find_commit(target)?; 869 let patches = repo.commit_patches(knot_git::PatchRange { 870 base: commit.parents.first().copied(), 871 head: target, 872 })?; 873 json( 874 DiffOut { 875 refspec: params.refspec.as_str().to_string(), 876 diff: nice_diff(&commit, &patches), 877 }, 878 limit, 879 ) 880 }) 881 .await 882} 883 884#[derive(Deserialize)] 885pub(crate) struct CompareParams { 886 repo: RepoArg, 887 #[serde(default)] 888 rev1: Revspec, 889 #[serde(default)] 890 rev2: Revspec, 891} 892 893#[derive(Serialize)] 894struct CompareOut { 895 rev1: String, 896 rev2: String, 897 #[serde(skip_serializing_if = "Vec::is_empty")] 898 format_patch: Vec<FormatPatchWire>, 899 #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] 900 patch_raw: String, 901 #[serde(skip_serializing_if = "Option::is_none")] 902 combined_patch: Option<Vec<FileWire>>, 903 #[serde(skip_serializing_if = "Option::is_none")] 904 combined_patch_raw: Option<String>, 905} 906 907fn format_patch_entry( 908 commit: &Commit, 909 patches: &[knot_git::FilePatch], 910 raw: &str, 911) -> FormatPatchWire { 912 let title = fold_subject(&commit.message); 913 let mut raw_headers: BTreeMap<String, Vec<String>> = BTreeMap::from([ 914 ( 915 "From".to_string(), 916 vec![format!("{} <{}>", commit.author.name, commit.author.email)], 917 ), 918 ( 919 "Date".to_string(), 920 vec![rfc2822( 921 commit.author.time.get(), 922 commit.author.offset_seconds, 923 )], 924 ), 925 ("Subject".to_string(), vec![format!("[PATCH] {title}")]), 926 ]); 927 if let Some(change_id) = commit.change_id() { 928 raw_headers.insert("Change-Id".to_string(), vec![change_id.to_string()]); 929 } 930 let files: Vec<FileWire> = patches.iter().map(FileWire::of).collect(); 931 FormatPatchWire { 932 files: (!files.is_empty()).then_some(files), 933 sha: commit.id, 934 author: Some(PatchIdentityWire { 935 name: commit.author.name.clone(), 936 email: commit.author.email.clone(), 937 }), 938 author_date: rfc3339(commit.author.time.get(), commit.author.offset_seconds), 939 committer: None, 940 committer_date: ZERO_TIME.to_string(), 941 title, 942 body: normalize_message_section(message_body(&commit.message).lines()), 943 subject_prefix: "[PATCH] ".to_string(), 944 body_appendix: normalize_message_section(appendix_lines(raw)), 945 raw_headers: Some(raw_headers), 946 raw: raw.trim().to_string(), 947 } 948} 949 950fn appendix_lines(raw: &str) -> impl Iterator<Item = &str> { 951 raw.split_once("\n---\n") 952 .map(|(_, rest)| rest) 953 .unwrap_or_default() 954 .split("\ndiff --git ") 955 .next() 956 .unwrap_or_default() 957 .lines() 958} 959 960pub(crate) async fn repo_compare<H: HttpTransport, C: Clock>( 961 State(state): State<Arc<XrpcState<H, C>>>, 962 ValidatedQuery(params): ValidatedQuery<CompareParams>, 963) -> Result<Response, XrpcError> { 964 let did = resolve_repo(&state, &params.repo)?; 965 let rev1 = params.rev1.as_str().to_string(); 966 if rev1.is_empty() { 967 return Err(XrpcError::invalid_request("missing rev1 parameter")); 968 } 969 let rev2 = params.rev2.as_str().to_string(); 970 if rev2.is_empty() { 971 return Err(XrpcError::invalid_request("missing rev2 parameter")); 972 } 973 let layout = state.layout.clone(); 974 let limit = state.byte_limits.response.get(); 975 run_blocking(move || { 976 let repo = open(&layout, &did)?; 977 let resolve = |rev: &str| { 978 let revision_not_found = || { 979 XrpcError::named( 980 StatusCode::BAD_REQUEST, 981 "RevisionNotFound", 982 format!("error resolving revision {rev}"), 983 ) 984 }; 985 if names_reserved(rev) { 986 return Err(revision_not_found()); 987 } 988 let commit = repo 989 .resolve_revision(rev) 990 .and_then(|oid| repo.peel_to_commit(oid).ok()) 991 .ok_or_else(revision_not_found)?; 992 match repo.reachable_from_public(commit) { 993 Ok(true) => Ok(commit), 994 Ok(false) => Err(revision_not_found()), 995 Err(error) => Err(error.into()), 996 } 997 }; 998 let base = resolve(&rev1)?; 999 let head = resolve(&rev2)?; 1000 let compare_error = |error: knot_git::GitError| { 1001 XrpcError::named( 1002 StatusCode::BAD_REQUEST, 1003 "CompareError", 1004 format!("error comparing revisions: {error}"), 1005 ) 1006 }; 1007 let between = repo 1008 .commits_between( 1009 CommitRange { base, head }, 1010 LogLimit::new(MAX_COMPARE_COMMITS + 1), 1011 ) 1012 .map_err(compare_error)?; 1013 if between.len() > MAX_COMPARE_COMMITS { 1014 return Err(XrpcError::named( 1015 StatusCode::BAD_REQUEST, 1016 "CompareError", 1017 format!("comparison spans more than maximum of {MAX_COMPARE_COMMITS} commits"), 1018 )); 1019 } 1020 let commits: Vec<Commit> = between 1021 .into_iter() 1022 .map(|oid| repo.find_commit(oid)) 1023 .collect::<Result<Vec<_>, _>>() 1024 .map_err(compare_error)? 1025 .into_iter() 1026 .rev() 1027 .filter(|commit| commit.parents.len() <= 1) 1028 .collect(); 1029 let entries: Vec<(FormatPatchWire, String)> = commits 1030 .iter() 1031 .map(|commit| { 1032 repo.commit_patches(knot_git::PatchRange { 1033 base: commit.parents.first().copied(), 1034 head: commit.id, 1035 }) 1036 .map(|patches| { 1037 let raw = render_format_patch(commit, &patches); 1038 (format_patch_entry(commit, &patches, &raw), raw) 1039 }) 1040 }) 1041 .collect::<Result<Vec<_>, _>>() 1042 .map_err(compare_error)?; 1043 let patch_raw: String = entries.iter().map(|(_, raw)| format!("{raw}\n")).collect(); 1044 let (combined_patch, combined_patch_raw) = match entries.len() >= 2 { 1045 true => repo 1046 .merge_base(base, head) 1047 .ok() 1048 .flatten() 1049 .and_then(|merge_base| { 1050 repo.commit_patches(knot_git::PatchRange { 1051 base: Some(merge_base), 1052 head, 1053 }) 1054 .ok() 1055 .map(|patches| { 1056 ( 1057 Some(patches.iter().map(FileWire::of).collect::<Vec<_>>()), 1058 Some(render_patches(&patches)), 1059 ) 1060 }) 1061 }) 1062 .unwrap_or((None, None)), 1063 false => (None, None), 1064 }; 1065 json( 1066 CompareOut { 1067 rev1: base.to_hex(), 1068 rev2: head.to_hex(), 1069 format_patch: entries.into_iter().map(|(entry, _)| entry).collect(), 1070 patch_raw, 1071 combined_patch, 1072 combined_patch_raw, 1073 }, 1074 limit, 1075 ) 1076 }) 1077 .await 1078} 1079 1080#[derive(Clone, Copy)] 1081struct ArchiveFormatArg(ArchiveFormat); 1082 1083impl Default for ArchiveFormatArg { 1084 fn default() -> Self { 1085 ArchiveFormatArg(ArchiveFormat::TarGz) 1086 } 1087} 1088 1089impl ArchiveFormatArg { 1090 fn format(self) -> ArchiveFormat { 1091 self.0 1092 } 1093 1094 fn name(self) -> &'static str { 1095 match self.0 { 1096 ArchiveFormat::Zip => "zip", 1097 _ => "tar.gz", 1098 } 1099 } 1100 1101 fn content_type(self) -> &'static str { 1102 match self.0 { 1103 ArchiveFormat::Zip => "application/zip", 1104 _ => "application/gzip", 1105 } 1106 } 1107} 1108 1109impl<'de> Deserialize<'de> for ArchiveFormatArg { 1110 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 1111 match String::deserialize(deserializer)?.as_str() { 1112 "" | "tar.gz" => Ok(ArchiveFormatArg(ArchiveFormat::TarGz)), 1113 "zip" => Ok(ArchiveFormatArg(ArchiveFormat::Zip)), 1114 _ => Err(de::Error::custom( 1115 "only tar.gz and zip formats are supported", 1116 )), 1117 } 1118 } 1119} 1120 1121#[derive(Default)] 1122struct ArchivePrefixArg(Option<knot_git::ArchivePrefix>); 1123 1124impl<'de> Deserialize<'de> for ArchivePrefixArg { 1125 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 1126 let raw = String::deserialize(deserializer)?; 1127 match raw.is_empty() { 1128 true => Ok(Self(None)), 1129 false => knot_git::ArchivePrefix::new(raw) 1130 .map(|prefix| Self(Some(prefix))) 1131 .map_err(|_| de::Error::custom("archive prefix mustn't escape archive root")), 1132 } 1133 } 1134} 1135 1136#[derive(Deserialize)] 1137pub(crate) struct ArchiveParams { 1138 repo: RepoArg, 1139 #[serde(rename = "ref", default)] 1140 refspec: Revspec, 1141 #[serde(default)] 1142 format: ArchiveFormatArg, 1143 #[serde(default)] 1144 prefix: ArchivePrefixArg, 1145} 1146 1147fn short_ref(refspec: &str) -> String { 1148 refspec 1149 .trim_start_matches("refs/heads/") 1150 .trim_start_matches("refs/tags/") 1151 .trim_start_matches("refs/remotes/") 1152 .replace('/', "-") 1153} 1154 1155fn sanitize_filename(name: &str) -> String { 1156 name.chars() 1157 .map(|c| match c.is_ascii_control() || matches!(c, '"' | '\\') { 1158 true => '-', 1159 false => c, 1160 }) 1161 .collect() 1162} 1163 1164fn rfc5987_encode(name: &str) -> String { 1165 name.bytes() 1166 .map(|byte| match byte { 1167 b'A'..=b'Z' 1168 | b'a'..=b'z' 1169 | b'0'..=b'9' 1170 | b'!' 1171 | b'#' 1172 | b'$' 1173 | b'&' 1174 | b'+' 1175 | b'-' 1176 | b'.' 1177 | b'^' 1178 | b'_' 1179 | b'`' 1180 | b'|' 1181 | b'~' => String::from(byte as char), 1182 _ => format!("%{byte:02X}"), 1183 }) 1184 .collect() 1185} 1186 1187fn content_disposition(filename: &str) -> String { 1188 let safe = sanitize_filename(filename); 1189 let ascii: String = safe 1190 .chars() 1191 .map(|c| match c.is_ascii() { 1192 true => c, 1193 false => '-', 1194 }) 1195 .collect(); 1196 match safe == ascii { 1197 true => format!("attachment; filename=\"{ascii}\""), 1198 false => format!( 1199 "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}", 1200 rfc5987_encode(&safe) 1201 ), 1202 } 1203} 1204 1205struct BoundedSpool { 1206 file: std::fs::File, 1207 position: u64, 1208 limit: u64, 1209 tripped: bool, 1210} 1211 1212impl std::io::Write for BoundedSpool { 1213 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> { 1214 if self.position.saturating_add(data.len() as u64) > self.limit { 1215 self.tripped = true; 1216 return Err(std::io::Error::new( 1217 std::io::ErrorKind::WriteZero, 1218 ARCHIVE_CAP_MESSAGE, 1219 )); 1220 } 1221 let written = self.file.write(data)?; 1222 self.position += written as u64; 1223 Ok(written) 1224 } 1225 1226 fn flush(&mut self) -> std::io::Result<()> { 1227 self.file.flush() 1228 } 1229} 1230 1231impl Seek for BoundedSpool { 1232 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> { 1233 let position = self.file.seek(pos)?; 1234 self.position = position; 1235 Ok(position) 1236 } 1237} 1238 1239fn archive_etag(did: &RepoDid, commit: Oid, format: ArchiveFormat, prefix: &str) -> String { 1240 let mut hasher = Sha256::new(); 1241 hasher.update(did.as_str().as_bytes()); 1242 hasher.update(b"\0"); 1243 hasher.update(commit.to_hex().as_bytes()); 1244 hasher.update(b"\0"); 1245 hasher.update(ArchiveFormatArg(format).name().as_bytes()); 1246 hasher.update(b"\0"); 1247 hasher.update(prefix.as_bytes()); 1248 quoted_etag(&hasher.finalize()) 1249} 1250 1251fn pinned_modified(modified_secs: i64) -> std::time::SystemTime { 1252 std::time::UNIX_EPOCH + std::time::Duration::from_secs(modified_secs.max(0) as u64) 1253} 1254 1255fn reconcile_if_range(request: &mut Request, etag: &str, modified_secs: i64) { 1256 let Some(value) = request 1257 .headers() 1258 .get(header::IF_RANGE) 1259 .and_then(|value| value.to_str().ok()) 1260 .map(str::trim) 1261 .map(str::to_string) 1262 else { 1263 return; 1264 }; 1265 let resumes = match value.starts_with('"') || value.starts_with("W/") { 1266 true => value.starts_with('"') && value == etag, 1267 false => httpdate::parse_http_date(&value) 1268 .is_ok_and(|client| client == pinned_modified(modified_secs)), 1269 }; 1270 let headers = request.headers_mut(); 1271 headers.remove(header::IF_RANGE); 1272 if !resumes { 1273 headers.remove(header::RANGE); 1274 } 1275} 1276 1277pub(crate) async fn repo_archive<H: HttpTransport, C: Clock>( 1278 State(state): State<Arc<XrpcState<H, C>>>, 1279 mut request: Request, 1280) -> Result<Response, XrpcError> { 1281 let params = Query::<ArchiveParams>::try_from_uri(request.uri()) 1282 .map_err(|rejection| XrpcError::invalid_request(rejection.body_text()))? 1283 .0; 1284 let did = resolve_repo(&state, &params.repo)?; 1285 let format = params.format; 1286 let format_name = format.name(); 1287 let repo_name = params.repo.basename().to_string(); 1288 let safe_ref = short_ref(params.refspec.as_str()); 1289 let archive_prefix = match &params.prefix.0 { 1290 None => format!("{repo_name}-{safe_ref}"), 1291 Some(prefix) => prefix.as_str().to_string(), 1292 }; 1293 1294 let (resolved, modified_secs) = run_blocking({ 1295 let layout = state.layout.clone(); 1296 let did = did.clone(); 1297 let refspec = params.refspec.clone(); 1298 move || { 1299 let repo = open(&layout, &did)?; 1300 let commit = commit_for(&repo, &refspec)?; 1301 let modified_secs = repo 1302 .find_commit(commit) 1303 .map(|commit| commit.committer.time.get()) 1304 .unwrap_or(0); 1305 Ok((commit, modified_secs)) 1306 } 1307 }) 1308 .await?; 1309 1310 let etag = archive_etag(&did, resolved, format.format(), &archive_prefix); 1311 if etag_matches(request.headers(), &etag) { 1312 return Ok(( 1313 StatusCode::NOT_MODIFIED, 1314 [ 1315 (header::ETAG, etag), 1316 (header::CACHE_CONTROL, "no-cache".to_string()), 1317 ], 1318 ) 1319 .into_response()); 1320 } 1321 1322 let temp = run_blocking({ 1323 let layout = state.layout.clone(); 1324 let did = did.clone(); 1325 let archive_limit = state.byte_limits.archive.get(); 1326 let tree_prefix = knot_git::ArchivePrefix::new(format!("{archive_prefix}/")) 1327 .expect("validated prefix with trailing slash stays valid"); 1328 move || { 1329 let repo = open(&layout, &did)?; 1330 let tree = repo.peel_to_tree(resolved)?; 1331 let temp = tempfile::NamedTempFile::new() 1332 .map_err(|error| XrpcError::internal(format!("cannot spool archive: {error}")))?; 1333 let file = temp 1334 .reopen() 1335 .map_err(|error| XrpcError::internal(format!("cannot spool archive: {error}")))?; 1336 let mut spool = BoundedSpool { 1337 file, 1338 position: 0, 1339 limit: archive_limit, 1340 tripped: false, 1341 }; 1342 repo.write_archive(tree, format.format(), Some(&tree_prefix), &mut spool) 1343 .map_err(|error| match spool.tripped { 1344 true => XrpcError::request_too_large(ARCHIVE_CAP_MESSAGE), 1345 false => XrpcError::named( 1346 StatusCode::BAD_REQUEST, 1347 "ArchiveError", 1348 format!("failed to create archive: {error}"), 1349 ), 1350 })?; 1351 temp.as_file() 1352 .set_modified(pinned_modified(modified_secs)) 1353 .map_err(|error| XrpcError::internal(error.to_string()))?; 1354 Ok(temp) 1355 } 1356 }) 1357 .await?; 1358 1359 let immutable = { 1360 let mut query = url::form_urlencoded::Serializer::new(String::new()); 1361 query.append_pair("format", format_name); 1362 query.append_pair("prefix", &archive_prefix); 1363 query.append_pair("ref", &resolved.to_hex()); 1364 query.append_pair("repo", &params.repo.to_param()); 1365 format!( 1366 "{}/xrpc/sh.tangled.repo.archive?{}", 1367 state.knot_service_url.as_str(), 1368 query.finish() 1369 ) 1370 }; 1371 let content_type = format.content_type(); 1372 let disposition = content_disposition(&format!("{repo_name}-{safe_ref}.{format_name}")); 1373 1374 reconcile_if_range(&mut request, &etag, modified_secs); 1375 let serve_response = ServeFile::new(temp.path()) 1376 .oneshot(request) 1377 .await 1378 .unwrap_or_else(|error| match error {}); 1379 drop(temp); 1380 1381 let mut response = serve_response.map(Body::new); 1382 let headers = response.headers_mut(); 1383 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); 1384 let header_value = |value: &str| { 1385 HeaderValue::from_str(value).map_err(|error| XrpcError::internal(error.to_string())) 1386 }; 1387 headers.insert(header::CONTENT_DISPOSITION, header_value(&disposition)?); 1388 headers.insert( 1389 header::LINK, 1390 header_value(&format!("<{immutable}>; rel=\"immutable\""))?, 1391 ); 1392 headers.insert(header::ETAG, header_value(&etag)?); 1393 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); 1394 Ok(response) 1395} 1396 1397#[derive(Deserialize)] 1398pub(crate) struct LanguagesParams { 1399 repo: RepoArg, 1400 #[serde(rename = "ref", default)] 1401 refspec: Revspec, 1402} 1403 1404#[derive(Serialize)] 1405struct LanguageOut { 1406 name: knot_types::LanguageName, 1407 size: knot_types::LanguageBytes, 1408 percentage: i64, 1409} 1410 1411#[derive(Serialize)] 1412struct LanguagesOut { 1413 #[serde(rename = "ref")] 1414 refspec: String, 1415 languages: Option<Vec<LanguageOut>>, 1416 #[serde(rename = "totalSize", skip_serializing_if = "Option::is_none")] 1417 total_size: Option<u64>, 1418 #[serde(rename = "totalFiles", skip_serializing_if = "Option::is_none")] 1419 total_files: Option<i64>, 1420} 1421 1422pub(crate) async fn repo_languages<H: HttpTransport, C: Clock>( 1423 State(state): State<Arc<XrpcState<H, C>>>, 1424 ValidatedQuery(params): ValidatedQuery<LanguagesParams>, 1425) -> Result<Response, XrpcError> { 1426 let did = resolve_repo(&state, &params.repo)?; 1427 let layout = state.layout.clone(); 1428 let limit = state.byte_limits.response.get(); 1429 let languages_deadline = state.budgets.languages.get().deadline(); 1430 run_blocking(move || { 1431 let repo = open(&layout, &did)?; 1432 let commit = commit_for(&repo, &params.refspec)?; 1433 let sizes = knot_langs::analyze(&repo, commit, languages_deadline)?; 1434 let total: u64 = sizes.values().map(|size| size.get()).sum(); 1435 let mut languages: Vec<LanguageOut> = sizes 1436 .iter() 1437 .filter(|(_, size)| size.get() > 0) 1438 .map(|(name, size)| LanguageOut { 1439 name: *name, 1440 size: *size, 1441 percentage: ((size.get() as f64) / (total as f64) * 100.0).round() as i64, 1442 }) 1443 .collect(); 1444 languages.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.name.cmp(&b.name))); 1445 let count = languages.len() as i64; 1446 json( 1447 LanguagesOut { 1448 refspec: params.refspec.as_str().to_string(), 1449 languages: (!languages.is_empty()).then_some(languages), 1450 total_size: (total > 0).then_some(total), 1451 total_files: (total > 0).then_some(count), 1452 }, 1453 limit, 1454 ) 1455 }) 1456 .await 1457} 1458 1459#[derive(Deserialize)] 1460pub(crate) struct DefaultBranchParams { 1461 repo: RepoArg, 1462} 1463 1464#[derive(Serialize)] 1465struct DefaultBranchOut { 1466 name: String, 1467 hash: String, 1468 when: String, 1469} 1470 1471pub(crate) async fn repo_get_default_branch<H: HttpTransport, C: Clock>( 1472 State(state): State<Arc<XrpcState<H, C>>>, 1473 ValidatedQuery(params): ValidatedQuery<DefaultBranchParams>, 1474) -> Result<Response, XrpcError> { 1475 let did = resolve_repo(&state, &params.repo)?; 1476 let layout = state.layout.clone(); 1477 let limit = state.byte_limits.response.get(); 1478 run_blocking(move || { 1479 let repo = open(&layout, &did)?; 1480 let name = repo 1481 .default_branch() 1482 .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()) 1483 .ok_or_else(|| { 1484 XrpcError::named( 1485 StatusCode::INTERNAL_SERVER_ERROR, 1486 "InvalidRequest", 1487 "failed to get default branch", 1488 ) 1489 })?; 1490 json( 1491 DefaultBranchOut { 1492 name, 1493 hash: String::new(), 1494 when: rfc3339(0, 0), 1495 }, 1496 limit, 1497 ) 1498 }) 1499 .await 1500} 1501 1502#[derive(Deserialize)] 1503pub(crate) struct DescribeRepoParams { 1504 #[serde(rename = "repoDid")] 1505 repo_did: RepoDid, 1506} 1507 1508#[derive(Serialize)] 1509struct DescribeRepoOut { 1510 #[serde(rename = "repoDid")] 1511 repo_did: RepoDid, 1512 #[serde(rename = "ownerDid")] 1513 owner_did: OwnerDid, 1514 rkey: RepoRkey, 1515} 1516 1517pub(crate) async fn repo_describe_repo<H: HttpTransport, C: Clock>( 1518 State(state): State<Arc<XrpcState<H, C>>>, 1519 ValidatedQuery(params): ValidatedQuery<DescribeRepoParams>, 1520) -> Result<Response, XrpcError> { 1521 let did = params.repo_did; 1522 let owner = match state.index.owner_of(&did) { 1523 Resolved::Ready(Some(owner)) => owner, 1524 Resolved::Ready(None) => return Err(repo_not_found()), 1525 Resolved::Warming => return Err(warming()), 1526 }; 1527 let rkey = match state.index.rkey_of(&did) { 1528 Resolved::Ready(Some(rkey)) => rkey, 1529 Resolved::Ready(None) => return Err(repo_not_found()), 1530 Resolved::Warming => return Err(warming()), 1531 }; 1532 json( 1533 DescribeRepoOut { 1534 repo_did: did, 1535 owner_did: owner, 1536 rkey, 1537 }, 1538 state.byte_limits.response.get(), 1539 ) 1540} 1541 1542#[derive(Serialize)] 1543struct DefaultBranchWire { 1544 #[serde(rename = "ref")] 1545 name: String, 1546 #[serde(skip_serializing_if = "Option::is_none")] 1547 head: Option<String>, 1548} 1549 1550#[derive(Deserialize)] 1551pub(crate) struct ListRefsParams { 1552 repo: RepoArg, 1553 #[serde(default)] 1554 limit: Limit<LIST_REFS_DEFAULT, LIST_REFS_MAX>, 1555 #[serde(default)] 1556 cursor: Offset, 1557} 1558 1559#[derive(Serialize)] 1560struct RefWire { 1561 #[serde(rename = "ref")] 1562 name: String, 1563 sha: Oid, 1564} 1565 1566#[derive(Serialize)] 1567struct ListRefsOut { 1568 refs: Vec<RefWire>, 1569 #[serde(skip_serializing_if = "Option::is_none")] 1570 cursor: Option<String>, 1571 #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] 1572 default_branch: Option<DefaultBranchWire>, 1573} 1574 1575pub(crate) async fn git_list_refs<H: HttpTransport, C: Clock>( 1576 State(state): State<Arc<XrpcState<H, C>>>, 1577 ValidatedQuery(params): ValidatedQuery<ListRefsParams>, 1578) -> Result<Response, XrpcError> { 1579 let did = resolve_repo(&state, &params.repo)?; 1580 let offset = params.cursor; 1581 let limit = params.limit; 1582 let layout = state.layout.clone(); 1583 let response_limit = state.byte_limits.response.get(); 1584 run_blocking(move || { 1585 let repo = open(&layout, &did)?; 1586 let mut refs: Vec<_> = repo 1587 .references()? 1588 .into_iter() 1589 .filter(|record| is_public_ref(&record.name)) 1590 .collect(); 1591 refs.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); 1592 let total = refs.len(); 1593 let window: Vec<RefWire> = refs 1594 .iter() 1595 .skip(offset.get()) 1596 .take(limit.get()) 1597 .map(|record| RefWire { 1598 name: record.name.as_str().to_string(), 1599 sha: record.target, 1600 }) 1601 .collect(); 1602 let cursor = next_cursor(offset, limit, Total::new(total)); 1603 let default_branch = repo.head().map(|head| DefaultBranchWire { 1604 name: head.name.as_str().to_string(), 1605 head: Some(head.target.to_hex()), 1606 }); 1607 json( 1608 ListRefsOut { 1609 refs: window, 1610 cursor, 1611 default_branch, 1612 }, 1613 response_limit, 1614 ) 1615 }) 1616 .await 1617} 1618 1619#[derive(Deserialize)] 1620pub(crate) struct ListReposParams { 1621 #[serde(default)] 1622 limit: Limit<LIST_REPOS_DEFAULT, LIST_REPOS_MAX>, 1623 #[serde(default)] 1624 cursor: Offset, 1625 #[serde(default)] 1626 order: Order, 1627} 1628 1629#[derive(Serialize)] 1630struct RepoWire { 1631 repo: RepoDid, 1632 status: &'static str, 1633 #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] 1634 default_branch: Option<DefaultBranchWire>, 1635} 1636 1637#[derive(Serialize)] 1638struct ListReposOut { 1639 repos: Vec<RepoWire>, 1640 #[serde(skip_serializing_if = "Option::is_none")] 1641 cursor: Option<String>, 1642} 1643 1644pub(crate) async fn sync_list_repos<H: HttpTransport, C: Clock>( 1645 State(state): State<Arc<XrpcState<H, C>>>, 1646 ValidatedQuery(params): ValidatedQuery<ListReposParams>, 1647) -> Result<Response, XrpcError> { 1648 if matches!(state.index.coverage().registry, Coverage::Warming) { 1649 return Err(warming()); 1650 } 1651 let offset = params.cursor; 1652 let limit = params.limit; 1653 let mut repos = state.index.hosted_repos(); 1654 if params.order.descending() { 1655 repos.reverse(); 1656 } 1657 let total = repos.len(); 1658 let page: Vec<RepoDid> = repos 1659 .into_iter() 1660 .skip(offset.get()) 1661 .take(limit.get()) 1662 .collect(); 1663 let cursor = next_cursor(offset, limit, Total::new(total)); 1664 let layout = state.layout.clone(); 1665 let response_limit = state.byte_limits.response.get(); 1666 run_blocking(move || { 1667 let repos: Vec<RepoWire> = 1668 page.iter() 1669 .map(|did| RepoWire { 1670 repo: did.clone(), 1671 status: "active", 1672 default_branch: open(&layout, did).ok().and_then(|repo| repo.head()).map( 1673 |head| DefaultBranchWire { 1674 name: head.name.as_str().to_string(), 1675 head: Some(head.target.to_hex()), 1676 }, 1677 ), 1678 }) 1679 .collect(); 1680 json(ListReposOut { repos, cursor }, response_limit) 1681 }) 1682 .await 1683} 1684 1685#[cfg(test)] 1686mod tests { 1687 use super::{content_disposition, rfc5987_encode}; 1688 1689 #[test] 1690 fn content_disposition_quotes_dashes_quotes_and_adds_an_encoded_form_for_non_ascii() { 1691 let cases: &[(&str, &str)] = &[ 1692 ( 1693 "squid-main.tar.gz", 1694 "attachment; filename=\"squid-main.tar.gz\"", 1695 ), 1696 ( 1697 "squid-a\"b.tar.gz", 1698 "attachment; filename=\"squid-a-b.tar.gz\"", 1699 ), 1700 ( 1701 "squid-café.zip", 1702 "attachment; filename=\"squid-caf-.zip\"; filename*=UTF-8''squid-caf%C3%A9.zip", 1703 ), 1704 ]; 1705 cases.iter().for_each(|(name, expected)| { 1706 assert_eq!(content_disposition(name), *expected); 1707 }); 1708 } 1709 1710 #[test] 1711 fn rfc5987_percent_encodes_outside_the_attr_char_set() { 1712 assert_eq!(rfc5987_encode("a b:c"), "a%20b%3Ac"); 1713 assert_eq!(rfc5987_encode("plain-._~"), "plain-._~"); 1714 } 1715}