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