This repository has no description
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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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 ¶ms.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", ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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}