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