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