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