use std::collections::BTreeMap; use std::sync::Arc; use axum::body::Body; use axum::extract::{Query, Request, State}; use axum::response::{IntoResponse, Response}; use http::{HeaderMap, HeaderValue, StatusCode, header}; use serde::de::{self, Deserializer}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tower::ServiceExt; use tower_http::services::ServeFile; use knot_git::{ ArchiveFormat, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, SizedEntry, is_public_ref, screens_reserved, }; use knot_index::{Coverage, Resolved}; use knot_runtime::{Clock, HttpTransport}; use knot_types::{AuthorName, Email, Oid, OwnerDid, RepoDid, RepoPath, RepoRkey}; use crate::error::XrpcError; use crate::patchtext::{render_format_patch, render_patches}; use crate::query::{ BranchArg, Limit, Offset, Order, RawFlag, RepoArg, Revspec, TagArg, Total, TreePath, ValidatedQuery, next_cursor, }; use crate::wire::{ BranchWire, CommitWire, FileWire, FormatPatchWire, PatchIdentityWire, TagWire, ZERO_TIME, fold_subject, message_body, nice_diff, normalize_message_section, rfc2822, rfc3339, }; use crate::{XrpcState, run_blocking, sniff}; pub(crate) const TREE_ROUTE: &str = "/xrpc/sh.tangled.repo.tree"; pub(crate) const LOG_ROUTE: &str = "/xrpc/sh.tangled.repo.log"; pub(crate) const BRANCHES_ROUTE: &str = "/xrpc/sh.tangled.repo.branches"; pub(crate) const BRANCH_ROUTE: &str = "/xrpc/sh.tangled.repo.branch"; pub(crate) const TAGS_ROUTE: &str = "/xrpc/sh.tangled.repo.tags"; pub(crate) const TAG_ROUTE: &str = "/xrpc/sh.tangled.repo.tag"; pub(crate) const BLOB_ROUTE: &str = "/xrpc/sh.tangled.repo.blob"; pub(crate) const DIFF_ROUTE: &str = "/xrpc/sh.tangled.repo.diff"; pub(crate) const COMPARE_ROUTE: &str = "/xrpc/sh.tangled.repo.compare"; pub(crate) const ARCHIVE_ROUTE: &str = "/xrpc/sh.tangled.repo.archive"; pub(crate) const LANGUAGES_ROUTE: &str = "/xrpc/sh.tangled.repo.languages"; pub(crate) const GET_DEFAULT_BRANCH_ROUTE: &str = "/xrpc/sh.tangled.repo.getDefaultBranch"; pub(crate) const DESCRIBE_REPO_ROUTE: &str = "/xrpc/sh.tangled.repo.describeRepo"; pub(crate) const LIST_REFS_ROUTE: &str = "/xrpc/sh.tangled.git.listRefs"; pub(crate) const LIST_REPOS_ROUTE: &str = "/xrpc/sh.tangled.sync.listRepos"; const DEFAULT_PAGE: usize = 50; const MAX_PAGE: usize = 100; const LIST_REFS_DEFAULT: usize = 100; const LIST_REFS_MAX: usize = 1000; const LIST_REPOS_DEFAULT: usize = 50; const LIST_REPOS_MAX: usize = 1000; const MAX_BLOB_BYTES: u64 = 25 * 1024 * 1024; const MAX_COMPARE_COMMITS: usize = 500; const RAW_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; pub(crate) fn repo_not_found() -> XrpcError { XrpcError::named( StatusCode::NOT_FOUND, "RepoNotFound", "repository not found on this knot", ) } fn ref_not_found() -> XrpcError { XrpcError::named( StatusCode::NOT_FOUND, "RefNotFound", "git reference not found", ) } fn blob_too_large() -> XrpcError { XrpcError::named( StatusCode::PAYLOAD_TOO_LARGE, "BlobTooLarge", "file is too large to serve", ) } fn blob_serving_limit(raw: bool, response_limit: usize) -> u64 { match raw { true => MAX_BLOB_BYTES, false => MAX_BLOB_BYTES.min(response_limit as u64 / 4 * 3), } } fn readme_serving_limit(response_limit: usize) -> u64 { response_limit as u64 / 8 } fn names_reserved(refspec: &str) -> bool { screens_reserved(refspec) || screens_reserved(&format!("refs/{refspec}")) } pub(crate) fn warming() -> XrpcError { XrpcError::warming("registry projection is still warming") } fn resolve_repo( state: &XrpcState, repo: &RepoArg, ) -> Result { match repo { RepoArg::Did(did) => match state.index.owner_of(did) { Resolved::Ready(Some(_)) => Ok(did.clone()), Resolved::Ready(None) => Err(repo_not_found()), Resolved::Warming => Err(warming()), }, RepoArg::OwnerRkey { owner, rkey } => match state.index.resolve_repo(owner, rkey) { Resolved::Ready(Some(did)) => Ok(did), Resolved::Ready(None) => Err(repo_not_found()), Resolved::Warming => Err(warming()), }, } } pub(crate) fn open(layout: &Layout, did: &RepoDid) -> Result { layout .open(did) .map_err(|error| XrpcError::internal(format!("cannot open repository: {error}"))) } fn commit_for(repo: &Repo, refspec: &Revspec) -> Result { let refspec = refspec.as_str(); if names_reserved(refspec) { return Err(ref_not_found()); } let oid = match refspec.is_empty() { true => repo.head().map(|head| head.target), false => repo.resolve_revision(refspec), } .ok_or_else(ref_not_found)?; let commit = repo.peel_to_commit(oid).map_err(|_| ref_not_found())?; if hidden_staging_commit(repo, refspec) == Some(commit) { return Ok(commit); } match repo.reachable_from_public(commit) { Ok(true) => Ok(commit), Ok(false) => Err(ref_not_found()), Err(error) => Err(error.into()), } } fn hidden_staging_commit(repo: &Repo, refspec: &str) -> Option { repo.hidden_ref_commit(refspec) .and_then(|oid| repo.peel_to_commit(oid).ok()) } struct LimitWriter { buf: Vec, limit: usize, } impl std::io::Write for LimitWriter { fn write(&mut self, data: &[u8]) -> std::io::Result { if self.buf.len() + data.len() > self.limit { return Err(std::io::Error::new( std::io::ErrorKind::WriteZero, "response exceeds configured maximum size", )); } self.buf.extend_from_slice(data); Ok(data.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } fn json(value: impl Serialize, limit: usize) -> Result { let mut writer = LimitWriter { buf: Vec::new(), limit, }; match serde_json::to_writer(&mut writer, &value) { Ok(()) => Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], writer.buf, ) .into_response()), Err(error) if error.is_io() => Err(XrpcError::request_too_large( "response exceeds configured maximum size", )), Err(error) => Err(XrpcError::internal(format!( "failed to serialize response: {error}" ))), } } #[derive(Deserialize)] pub(crate) struct TreeParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, #[serde(default)] path: TreePath, } #[derive(Serialize)] struct SignatureOut { name: AuthorName, email: Email, when: String, } #[derive(Serialize)] struct LastCommitOut { hash: Oid, message: String, when: String, #[serde(skip_serializing_if = "Option::is_none")] author: Option, } #[derive(Serialize)] struct TreeEntryOut { name: String, mode: String, size: i64, #[serde(skip_serializing_if = "Option::is_none")] last_commit: Option, } #[derive(Serialize)] struct ReadmeOut { filename: String, contents: String, } #[derive(Serialize)] struct TreeOut { #[serde(rename = "ref")] refspec: String, #[serde(skip_serializing_if = "Option::is_none")] parent: Option, #[serde(skip_serializing_if = "Option::is_none")] dotdot: Option, files: Vec, #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] last_commit: Option, readme: ReadmeOut, } fn is_readme(entry: &SizedEntry) -> bool { let lower = entry.name.to_ascii_lowercase(); entry.kind.is_file() && (lower == "readme" || lower .strip_prefix("readme.") .is_some_and(|extension| !extension.is_empty() && !extension.contains('.'))) } fn readme_of( repo: &Repo, commit: Oid, dir: Option<&RepoPath>, entries: &[SizedEntry], response_limit: usize, ) -> ReadmeOut { entries .iter() .filter(|entry| is_readme(entry)) .find_map(|entry| { let path = match dir { None => RepoPath::new(entry.name.as_str()).ok()?, Some(dir) => RepoPath::new(format!("{dir}/{}", entry.name)).ok()?, }; let target = repo.entry_at(commit, &path).ok().flatten()?; if repo.blob_size(target.oid).ok()? > readme_serving_limit(response_limit) { return None; } let contents = repo.read_blob(target.oid).ok()?; String::from_utf8(contents).ok().map(|contents| ReadmeOut { filename: entry.name.clone(), contents, }) }) .unwrap_or(ReadmeOut { filename: String::new(), contents: String::new(), }) } pub(crate) async fn repo_tree( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); let tree_deadline = state.budgets.tree_last_commit.get().deadline(); run_blocking(move || { let repo = open(&layout, &did)?; let commit = commit_for(&repo, ¶ms.refspec)?; let path_not_found = || { XrpcError::named( StatusCode::NOT_FOUND, "PathNotFound", "path not found in repository tree", ) }; let dir = params.path.dir().ok_or_else(path_not_found)?; let path = params.path.as_str(); let entries = repo .tree_entries_at(commit, dir)? .ok_or_else(path_not_found)?; let names: Vec = entries.iter().map(|entry| entry.name.clone()).collect(); let attributed = repo .last_commits(commit, dir, &names, tree_deadline) .unwrap_or_default(); let files: Vec = entries .iter() .map(|entry| TreeEntryOut { name: entry.name.clone(), mode: entry.kind.mode_octal().to_string(), size: entry.size as i64, last_commit: attributed.get(&entry.name).map(|last| LastCommitOut { hash: last.id, message: last.subject.clone(), when: rfc3339(last.time.get(), 0), author: None, }), }) .collect(); let newest = attributed.values().max_by_key(|last| (last.time, last.id)); let last_commit = newest.map(|last| LastCommitOut { hash: last.id, message: last.subject.clone(), when: rfc3339(last.time.get(), 0), author: repo.find_commit(last.id).ok().map(|commit| SignatureOut { name: commit.author.name, email: commit.author.email, when: String::new(), }), }); let readme = readme_of(&repo, commit, dir, &entries, limit); let parent = (!path.is_empty()).then(|| path.to_string()); let dotdot = (!path.is_empty()) .then(|| path.rsplit_once('/').map(|(parent, _)| parent.to_string())) .flatten(); json( TreeOut { refspec: params.refspec.as_str().to_string(), parent, dotdot, files, last_commit, readme, }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct LogParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, #[serde(default)] path: TreePath, #[serde(default)] limit: Limit, #[serde(default)] cursor: Offset, } #[derive(Serialize)] struct LogOut { #[serde(skip_serializing_if = "Vec::is_empty")] commits: Vec, #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] refspec: String, #[serde(skip_serializing_if = "String::is_empty")] description: String, log: bool, #[serde(skip_serializing_if = "is_zero")] total: usize, page: usize, per_page: usize, } fn is_zero(value: &usize) -> bool { *value == 0 } pub(crate) async fn repo_log( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let offset = params.cursor.get(); let limit = params.limit.get(); let layout = state.layout.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let start = commit_for(&repo, ¶ms.refspec)?; let (commits, total) = repo.log_window(start, LogSkip::new(offset), LogLimit::new(limit))?; json( LogOut { commits: commits.iter().map(CommitWire::of).collect(), refspec: params.refspec.as_str().to_string(), description: params.path.as_str().to_string(), log: true, total, page: (offset / limit) + 1, per_page: limit, }, response_limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct BranchesParams { repo: RepoArg, #[serde(default)] limit: Limit, #[serde(default)] cursor: Offset, } #[derive(Serialize)] struct BranchesOut { #[serde(skip_serializing_if = "Vec::is_empty")] branches: Vec, } pub(crate) async fn repo_branches( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let offset = params.cursor.get(); let limit = params.limit.get(); let layout = state.layout.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let mut branches = repo.branch_list()?; branches.sort_by(|a, b| { b.tip .created_at() .cmp(&a.tip.created_at()) .then_with(|| a.name.cmp(&b.name)) }); let default = repo .default_branch() .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()); let absent = repo.object_format().null_oid(); let window: Vec = branches .iter() .skip(offset) .take(limit) .map(|branch| { BranchWire::of( branch, default.as_deref() == Some(branch.name.as_str()), absent, ) }) .rev() .collect(); json(BranchesOut { branches: window }, response_limit) }) .await } #[derive(Deserialize)] pub(crate) struct BranchParams { repo: RepoArg, #[serde(default)] name: BranchArg, } #[derive(Serialize)] struct BranchOut { name: String, hash: String, #[serde(rename = "shortHash")] short_hash: String, when: String, #[serde(skip_serializing_if = "Option::is_none")] message: Option, author: SignatureOut, #[serde(rename = "isDefault")] is_default: bool, } pub(crate) async fn repo_branch( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let Some(name) = params.name.get().cloned() else { return Err(XrpcError::invalid_request("missing name parameter")); }; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let branch_not_found = || XrpcError::named(StatusCode::NOT_FOUND, "BranchNotFound", "branch not found"); let target = repo .find_ref(&name.head_ref()) .ok() .flatten() .ok_or_else(branch_not_found)?; let commit = repo.find_commit(target).map_err(|_| branch_not_found())?; let default = repo .default_branch() .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()); let hash = target.to_hex(); json( BranchOut { name: name.to_string(), short_hash: hash[..7].to_string(), hash, when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), message: (!commit.message.is_empty()).then(|| commit.message.clone()), author: SignatureOut { name: commit.author.name.clone(), email: commit.author.email.clone(), when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), }, is_default: default.as_deref() == Some(name.as_str()), }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct TagsParams { repo: RepoArg, #[serde(default)] limit: Limit, #[serde(default)] cursor: Offset, } #[derive(Serialize)] struct TagsOut { #[serde(skip_serializing_if = "Vec::is_empty")] tags: Vec, } pub(crate) async fn repo_tags( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let offset = params.cursor.get(); let limit = params.limit.get(); let layout = state.layout.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let mut tags = repo.tag_list()?; tags.sort_by(|a, b| { b.created_at .cmp(&a.created_at) .then_with(|| a.name.cmp(&b.name)) }); let window: Vec = tags .iter() .skip(offset) .take(limit) .map(TagWire::of) .collect(); json(TagsOut { tags: window }, response_limit) }) .await } #[derive(Deserialize)] pub(crate) struct TagParams { repo: RepoArg, #[serde(default)] tag: TagArg, } #[derive(Serialize)] struct TagOut { tag: TagWire, } pub(crate) async fn repo_tag( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let Some(name) = params.tag.get().cloned() else { return Err(XrpcError::invalid_request("missing tag parameter")); }; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let info = repo .tag_list()? .into_iter() .find(|tag| tag.name == name) .ok_or_else(|| { XrpcError::named(StatusCode::BAD_REQUEST, "TagNotFound", "tag not found") })?; json( TagOut { tag: TagWire::of(&info), }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct BlobParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, #[serde(default)] path: TreePath, #[serde(default)] raw: RawFlag, } #[derive(Serialize)] struct SubmoduleOut { name: String, url: String, branch: String, } #[derive(Serialize)] struct BlobOut { #[serde(rename = "ref")] refspec: String, path: String, #[serde(skip_serializing_if = "Option::is_none")] content: Option, #[serde(skip_serializing_if = "Option::is_none")] encoding: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] size: Option, #[serde(rename = "isBinary", skip_serializing_if = "Option::is_none")] is_binary: Option, #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] mime_type: Option<&'static str>, #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] last_commit: Option, #[serde(skip_serializing_if = "Option::is_none")] submodule: Option, } fn etag_matches(headers: &HeaderMap, etag: &str) -> bool { headers .get_all(header::IF_NONE_MATCH) .iter() .filter_map(|value| value.to_str().ok()) .flat_map(|value| value.split(',')) .map(str::trim) .any(|candidate| { candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag }) } fn quoted_etag(digest: &[u8]) -> String { format!("\"{}\"", knot_types::lowercase_hex(digest)) } fn serve_raw( headers: &HeaderMap, mime: &'static str, contents: Vec, ) -> Result { if mime.starts_with("image/") || mime.starts_with("video/") { let etag = quoted_etag(&Sha256::digest(&contents)); if etag_matches(headers, &etag) { return Ok(StatusCode::NOT_MODIFIED.into_response()); } return Ok(( StatusCode::OK, [ (header::ETAG, etag), (header::CONTENT_TYPE, mime.to_string()), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), (header::CONTENT_SECURITY_POLICY, RAW_CSP.to_string()), ], contents, ) .into_response()); } if sniff::is_textual_mime(mime) { return Ok(( StatusCode::OK, [ (header::CACHE_CONTROL, "public, no-cache".to_string()), ( header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string(), ), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), (header::CONTENT_SECURITY_POLICY, RAW_CSP.to_string()), ], contents, ) .into_response()); } Err(XrpcError::named( StatusCode::FORBIDDEN, "InvalidRequest", "only image, video, and text files can be accessed directly", )) } pub(crate) async fn repo_blob( State(state): State>>, ValidatedQuery(params): ValidatedQuery, headers: HeaderMap, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; if params.path.as_str().is_empty() { return Err(XrpcError::invalid_request("missing path parameter")); } let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); let blob_deadline = state.budgets.blob_last_commit.get().deadline(); run_blocking(move || { let refspec = params.refspec.as_str().to_string(); let path = params.path.as_str().to_string(); let raw = params.raw.requested(); let repo = open(&layout, &did)?; let commit = commit_for(&repo, ¶ms.refspec)?; let submodule = repo .submodules(commit) .unwrap_or_default() .into_iter() .find(|submodule| submodule.path.as_str() == path); if let Some(submodule) = submodule { return json( BlobOut { refspec, path, content: None, encoding: None, size: None, is_binary: None, mime_type: None, last_commit: None, submodule: Some(SubmoduleOut { name: submodule.name, url: submodule.url, branch: submodule .branch .map(|branch| branch.to_string()) .unwrap_or_default(), }), }, limit, ); } let file_not_found = || { XrpcError::named( StatusCode::NOT_FOUND, "FileNotFound", "file not found at specified path", ) }; let file_path = params.path.file().ok_or_else(file_not_found)?; let entry = repo .entry_at(commit, file_path)? .filter(|entry| { matches!( entry.kind, EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link ) }) .ok_or_else(file_not_found)?; if repo.blob_size(entry.oid).map_err(|_| file_not_found())? > blob_serving_limit(raw, limit) { return Err(blob_too_large()); } let contents = repo.read_blob(entry.oid).map_err(|_| file_not_found())?; let mime = sniff::override_by_extension(&path, sniff::detect_content_type(&contents)); if raw { return serve_raw(&headers, mime, contents); } let is_binary = !sniff::is_textual_mime(mime); let size = contents.len() as i64; let (content, encoding) = match is_binary { true => ( base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &contents), "base64", ), false => (String::from_utf8_lossy(&contents).into_owned(), "utf-8"), }; let dir = file_path.parent(); let name = file_path.file_name().to_string(); let last_commit = repo .last_commits( commit, dir.as_ref(), std::slice::from_ref(&name), blob_deadline, ) .ok() .and_then(|attributed| attributed.get(&name).cloned()) .map(|last| LastCommitOut { hash: last.id, message: last.subject, when: rfc3339(last.time.get(), 0), author: repo.find_commit(last.id).ok().map(|commit| SignatureOut { name: commit.author.name, email: commit.author.email, when: String::new(), }), }); json( BlobOut { refspec, path, content: Some(content), encoding: Some(encoding), size: Some(size), is_binary: Some(is_binary), mime_type: Some(mime), last_commit, submodule: None, }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct DiffParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, } #[derive(Serialize)] struct DiffOut { #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] refspec: String, diff: crate::wire::NiceDiffWire, } pub(crate) async fn repo_diff( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let target = commit_for(&repo, ¶ms.refspec)?; let commit = repo.find_commit(target)?; let patches = repo.commit_patches(knot_git::PatchRange { base: commit.parents.first().copied(), head: target, })?; json( DiffOut { refspec: params.refspec.as_str().to_string(), diff: nice_diff(&commit, &patches), }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct CompareParams { repo: RepoArg, #[serde(default)] rev1: Revspec, #[serde(default)] rev2: Revspec, } #[derive(Serialize)] struct CompareOut { rev1: String, rev2: String, #[serde(skip_serializing_if = "Vec::is_empty")] format_patch: Vec, #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] patch_raw: String, #[serde(skip_serializing_if = "Option::is_none")] combined_patch: Option>, #[serde(skip_serializing_if = "Option::is_none")] combined_patch_raw: Option, } fn format_patch_entry( commit: &Commit, patches: &[knot_git::FilePatch], raw: &str, ) -> FormatPatchWire { let title = fold_subject(&commit.message); let mut raw_headers: BTreeMap> = BTreeMap::from([ ( "From".to_string(), vec![format!("{} <{}>", commit.author.name, commit.author.email)], ), ( "Date".to_string(), vec![rfc2822( commit.author.time.get(), commit.author.offset_seconds, )], ), ("Subject".to_string(), vec![format!("[PATCH] {title}")]), ]); if let Some(change_id) = commit.change_id() { raw_headers.insert("Change-Id".to_string(), vec![change_id.to_string()]); } let files: Vec = patches.iter().map(FileWire::of).collect(); FormatPatchWire { files: (!files.is_empty()).then_some(files), sha: commit.id, author: Some(PatchIdentityWire { name: commit.author.name.clone(), email: commit.author.email.clone(), }), author_date: rfc3339(commit.author.time.get(), commit.author.offset_seconds), committer: None, committer_date: ZERO_TIME.to_string(), title, body: normalize_message_section(message_body(&commit.message).lines()), subject_prefix: "[PATCH] ".to_string(), body_appendix: normalize_message_section(appendix_lines(raw)), raw_headers: Some(raw_headers), raw: raw.trim().to_string(), } } fn appendix_lines(raw: &str) -> impl Iterator { raw.split_once("\n---\n") .map(|(_, rest)| rest) .unwrap_or_default() .split("\ndiff --git ") .next() .unwrap_or_default() .lines() } pub(crate) async fn repo_compare( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let rev1 = params.rev1.as_str().to_string(); if rev1.is_empty() { return Err(XrpcError::invalid_request("missing rev1 parameter")); } let rev2 = params.rev2.as_str().to_string(); if rev2.is_empty() { return Err(XrpcError::invalid_request("missing rev2 parameter")); } let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let resolve = |rev: &str| { let revision_not_found = || { XrpcError::named( StatusCode::BAD_REQUEST, "RevisionNotFound", format!("error resolving revision {rev}"), ) }; if names_reserved(rev) { return Err(revision_not_found()); } let commit = repo .resolve_revision(rev) .and_then(|oid| repo.peel_to_commit(oid).ok()) .ok_or_else(revision_not_found)?; if hidden_staging_commit(&repo, rev) == Some(commit) { return Ok(commit); } match repo.reachable_from_public(commit) { Ok(true) => Ok(commit), Ok(false) => Err(revision_not_found()), Err(error) => Err(error.into()), } }; let base = resolve(&rev1)?; let head = resolve(&rev2)?; let compare_error = |error: knot_git::GitError| { XrpcError::named( StatusCode::BAD_REQUEST, "CompareError", format!("error comparing revisions: {error}"), ) }; let between = repo .commits_between( CommitRange { base, head }, LogLimit::new(MAX_COMPARE_COMMITS + 1), ) .map_err(compare_error)?; if between.len() > MAX_COMPARE_COMMITS { return Err(XrpcError::named( StatusCode::BAD_REQUEST, "CompareError", format!("comparison spans more than maximum of {MAX_COMPARE_COMMITS} commits"), )); } let commits: Vec = between .into_iter() .map(|oid| repo.find_commit(oid)) .collect::, _>>() .map_err(compare_error)? .into_iter() .rev() .filter(|commit| commit.parents.len() <= 1) .collect(); let entries: Vec<(FormatPatchWire, String)> = commits .iter() .map(|commit| { repo.commit_patches(knot_git::PatchRange { base: commit.parents.first().copied(), head: commit.id, }) .map(|patches| { let raw = render_format_patch(commit, &patches); (format_patch_entry(commit, &patches, &raw), raw) }) }) .collect::, _>>() .map_err(compare_error)?; let patch_raw: String = entries.iter().map(|(_, raw)| format!("{raw}\n")).collect(); let (combined_patch, combined_patch_raw) = match entries.len() >= 2 { true => repo .merge_base(base, head) .ok() .flatten() .and_then(|merge_base| { repo.commit_patches(knot_git::PatchRange { base: Some(merge_base), head, }) .ok() .map(|patches| { ( Some(patches.iter().map(FileWire::of).collect::>()), Some(render_patches(&patches)), ) }) }) .unwrap_or((None, None)), false => (None, None), }; json( CompareOut { rev1: base.to_hex(), rev2: head.to_hex(), format_patch: entries.into_iter().map(|(entry, _)| entry).collect(), patch_raw, combined_patch, combined_patch_raw, }, limit, ) }) .await } #[derive(Clone, Copy)] struct ArchiveFormatArg(ArchiveFormat); impl Default for ArchiveFormatArg { fn default() -> Self { ArchiveFormatArg(ArchiveFormat::TarGz) } } impl ArchiveFormatArg { fn format(self) -> ArchiveFormat { self.0 } fn name(self) -> &'static str { match self.0 { ArchiveFormat::Zip => "zip", _ => "tar.gz", } } fn content_type(self) -> &'static str { match self.0 { ArchiveFormat::Zip => "application/zip", _ => "application/gzip", } } } impl<'de> Deserialize<'de> for ArchiveFormatArg { fn deserialize>(deserializer: D) -> Result { match String::deserialize(deserializer)?.as_str() { "" | "tar.gz" => Ok(ArchiveFormatArg(ArchiveFormat::TarGz)), "zip" => Ok(ArchiveFormatArg(ArchiveFormat::Zip)), _ => Err(de::Error::custom( "only tar.gz and zip formats are supported", )), } } } #[derive(Default)] struct ArchivePrefixArg(Option); impl<'de> Deserialize<'de> for ArchivePrefixArg { fn deserialize>(deserializer: D) -> Result { let raw = String::deserialize(deserializer)?; match raw.is_empty() { true => Ok(Self(None)), false => knot_git::ArchivePrefix::new(raw) .map(|prefix| Self(Some(prefix))) .map_err(|_| de::Error::custom("archive prefix mustn't escape archive root")), } } } #[derive(Deserialize)] pub(crate) struct ArchiveParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, #[serde(default)] format: ArchiveFormatArg, #[serde(default)] prefix: ArchivePrefixArg, } fn short_ref(refspec: &str) -> String { refspec .trim_start_matches("refs/heads/") .trim_start_matches("refs/tags/") .trim_start_matches("refs/remotes/") .replace('/', "-") } fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c.is_ascii_control() || matches!(c, '"' | '\\') { true => '-', false => c, }) .collect() } fn rfc5987_encode(name: &str) -> String { name.bytes() .map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' => String::from(byte as char), _ => format!("%{byte:02X}"), }) .collect() } fn content_disposition(filename: &str) -> String { let safe = sanitize_filename(filename); let ascii: String = safe .chars() .map(|c| match c.is_ascii() { true => c, false => '-', }) .collect(); match safe == ascii { true => format!("attachment; filename=\"{ascii}\""), false => format!( "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}", rfc5987_encode(&safe) ), } } fn archive_etag(did: &RepoDid, commit: Oid, format: ArchiveFormat, prefix: &str) -> String { let mut hasher = Sha256::new(); hasher.update(did.as_str().as_bytes()); hasher.update(b"\0"); hasher.update(commit.to_hex().as_bytes()); hasher.update(b"\0"); hasher.update(ArchiveFormatArg(format).name().as_bytes()); hasher.update(b"\0"); hasher.update(prefix.as_bytes()); quoted_etag(&hasher.finalize()) } fn pinned_modified(modified_secs: i64) -> std::time::SystemTime { std::time::UNIX_EPOCH + std::time::Duration::from_secs(modified_secs.max(0) as u64) } fn reconcile_if_range(request: &mut Request, etag: &str, modified_secs: i64) { let Some(value) = request .headers() .get(header::IF_RANGE) .and_then(|value| value.to_str().ok()) .map(str::trim) .map(str::to_string) else { return; }; let resumes = match value.starts_with('"') || value.starts_with("W/") { true => value.starts_with('"') && value == etag, false => httpdate::parse_http_date(&value) .is_ok_and(|client| client == pinned_modified(modified_secs)), }; let headers = request.headers_mut(); headers.remove(header::IF_RANGE); if !resumes { headers.remove(header::RANGE); } } pub(crate) async fn repo_archive( State(state): State>>, mut request: Request, ) -> Result { let params = Query::::try_from_uri(request.uri()) .map_err(|rejection| XrpcError::invalid_request(rejection.body_text()))? .0; let did = resolve_repo(&state, ¶ms.repo)?; let format = params.format; let format_name = format.name(); let repo_name = params.repo.basename().to_string(); let safe_ref = short_ref(params.refspec.as_str()); let archive_prefix = match ¶ms.prefix.0 { None => format!("{repo_name}-{safe_ref}"), Some(prefix) => prefix.as_str().to_string(), }; let (resolved, modified_secs) = run_blocking({ let layout = state.layout.clone(); let did = did.clone(); let refspec = params.refspec.clone(); move || { let repo = open(&layout, &did)?; let commit = commit_for(&repo, &refspec)?; let modified_secs = repo .find_commit(commit) .map(|commit| commit.committer.time.get()) .unwrap_or(0); Ok((commit, modified_secs)) } }) .await?; let etag = archive_etag(&did, resolved, format.format(), &archive_prefix); if etag_matches(request.headers(), &etag) { return Ok(( StatusCode::NOT_MODIFIED, [ (header::ETAG, etag), (header::CACHE_CONTROL, "no-cache".to_string()), ], ) .into_response()); } let temp = run_blocking({ let layout = state.layout.clone(); let did = did.clone(); let archive_limit = state.byte_limits.archive; let tree_prefix = knot_git::ArchivePrefix::new(format!("{archive_prefix}/")) .expect("validated prefix with trailing slash stays valid"); move || { let repo = open(&layout, &did)?; let tree = repo.peel_to_tree(resolved)?; let temp = tempfile::NamedTempFile::new() .map_err(|error| XrpcError::internal(format!("cannot spool archive: {error}")))?; let mut file = temp .reopen() .map_err(|error| XrpcError::internal(format!("cannot spool archive: {error}")))?; repo.write_archive( tree, format.format(), Some(&tree_prefix), archive_limit, &mut file, ) .map_err(|error| { match matches!(error, knot_git::GitError::ArchiveTooLarge { .. }) { true => XrpcError::from(error), false => XrpcError::named( StatusCode::BAD_REQUEST, "ArchiveError", format!("failed to create archive: {error}"), ), } })?; temp.as_file() .set_modified(pinned_modified(modified_secs)) .map_err(|error| XrpcError::internal(error.to_string()))?; Ok(temp) } }) .await?; let immutable = { let mut query = url::form_urlencoded::Serializer::new(String::new()); query.append_pair("format", format_name); query.append_pair("prefix", &archive_prefix); query.append_pair("ref", &resolved.to_hex()); query.append_pair("repo", ¶ms.repo.to_param()); format!( "{}/xrpc/sh.tangled.repo.archive?{}", state.knot_service_url.as_str(), query.finish() ) }; let content_type = format.content_type(); let disposition = content_disposition(&format!("{repo_name}-{safe_ref}.{format_name}")); reconcile_if_range(&mut request, &etag, modified_secs); let serve_response = ServeFile::new(temp.path()) .oneshot(request) .await .unwrap_or_else(|error| match error {}); drop(temp); let mut response = serve_response.map(Body::new); let headers = response.headers_mut(); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); let header_value = |value: &str| { HeaderValue::from_str(value).map_err(|error| XrpcError::internal(error.to_string())) }; headers.insert(header::CONTENT_DISPOSITION, header_value(&disposition)?); headers.insert( header::LINK, header_value(&format!("<{immutable}>; rel=\"immutable\""))?, ); headers.insert(header::ETAG, header_value(&etag)?); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); Ok(response) } #[derive(Deserialize)] pub(crate) struct LanguagesParams { repo: RepoArg, #[serde(rename = "ref", default)] refspec: Revspec, } #[derive(Serialize)] struct LanguageOut { name: knot_types::LanguageName, size: knot_types::LanguageBytes, percentage: i64, } #[derive(Serialize)] struct LanguagesOut { #[serde(rename = "ref")] refspec: String, languages: Option>, #[serde(rename = "totalSize", skip_serializing_if = "Option::is_none")] total_size: Option, #[serde(rename = "totalFiles", skip_serializing_if = "Option::is_none")] total_files: Option, } pub(crate) async fn repo_languages( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); let languages_deadline = state.budgets.languages.get().deadline(); run_blocking(move || { let repo = open(&layout, &did)?; let commit = commit_for(&repo, ¶ms.refspec)?; let sizes = knot_langs::analyze(&repo, commit, languages_deadline)?; let total: u64 = sizes.values().map(|size| size.get()).sum(); let mut languages: Vec = sizes .iter() .filter(|(_, size)| size.get() > 0) .map(|(name, size)| LanguageOut { name: *name, size: *size, percentage: ((size.get() as f64) / (total as f64) * 100.0).round() as i64, }) .collect(); languages.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.name.cmp(&b.name))); let count = languages.len() as i64; json( LanguagesOut { refspec: params.refspec.as_str().to_string(), languages: (!languages.is_empty()).then_some(languages), total_size: (total > 0).then_some(total), total_files: (total > 0).then_some(count), }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct DefaultBranchParams { repo: RepoArg, } #[derive(Serialize)] struct DefaultBranchOut { name: String, hash: String, when: String, } pub(crate) async fn repo_get_default_branch( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let name = repo .default_branch() .map(|name| name.as_str().trim_start_matches("refs/heads/").to_string()) .ok_or_else(|| { XrpcError::named( StatusCode::INTERNAL_SERVER_ERROR, "InvalidRequest", "failed to get default branch", ) })?; json( DefaultBranchOut { name, hash: String::new(), when: rfc3339(0, 0), }, limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct DescribeRepoParams { #[serde(rename = "repoDid")] repo_did: RepoDid, } #[derive(Serialize)] struct DescribeRepoOut { #[serde(rename = "repoDid")] repo_did: RepoDid, #[serde(rename = "ownerDid")] owner_did: OwnerDid, rkey: RepoRkey, } pub(crate) async fn repo_describe_repo( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = params.repo_did; let owner = match state.index.owner_of(&did) { Resolved::Ready(Some(owner)) => owner, Resolved::Ready(None) => return Err(repo_not_found()), Resolved::Warming => return Err(warming()), }; let rkey = match state.index.rkey_of(&did) { Resolved::Ready(Some(rkey)) => rkey, Resolved::Ready(None) => return Err(repo_not_found()), Resolved::Warming => return Err(warming()), }; json( DescribeRepoOut { repo_did: did, owner_did: owner, rkey, }, state.byte_limits.response.get(), ) } #[derive(Serialize)] struct DefaultBranchWire { #[serde(rename = "ref")] name: String, #[serde(skip_serializing_if = "Option::is_none")] head: Option, } #[derive(Deserialize)] pub(crate) struct ListRefsParams { repo: RepoArg, #[serde(default)] limit: Limit, #[serde(default)] cursor: Offset, } #[derive(Serialize)] struct RefWire { #[serde(rename = "ref")] name: String, sha: Oid, } #[derive(Serialize)] struct ListRefsOut { refs: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] default_branch: Option, } pub(crate) async fn git_list_refs( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; let offset = params.cursor; let limit = params.limit; let layout = state.layout.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { let repo = open(&layout, &did)?; let mut refs: Vec<_> = repo .references()? .into_iter() .filter(|record| is_public_ref(&record.name)) .collect(); refs.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); let total = refs.len(); let window: Vec = refs .iter() .skip(offset.get()) .take(limit.get()) .map(|record| RefWire { name: record.name.as_str().to_string(), sha: record.target, }) .collect(); let cursor = next_cursor(offset, limit, Total::new(total)); let default_branch = repo.head().map(|head| DefaultBranchWire { name: head.name.as_str().to_string(), head: Some(head.target.to_hex()), }); json( ListRefsOut { refs: window, cursor, default_branch, }, response_limit, ) }) .await } #[derive(Deserialize)] pub(crate) struct ListReposParams { #[serde(default)] limit: Limit, #[serde(default)] cursor: Offset, #[serde(default)] order: Order, } #[derive(Serialize)] struct RepoWire { repo: RepoDid, status: &'static str, #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] default_branch: Option, } #[derive(Serialize)] struct ListReposOut { repos: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } pub(crate) async fn sync_list_repos( State(state): State>>, ValidatedQuery(params): ValidatedQuery, ) -> Result { if matches!(state.index.coverage().registry, Coverage::Warming) { return Err(warming()); } let offset = params.cursor; let limit = params.limit; let mut repos = state.index.hosted_repos(); if params.order.descending() { repos.reverse(); } let total = repos.len(); let page: Vec = repos .into_iter() .skip(offset.get()) .take(limit.get()) .collect(); let cursor = next_cursor(offset, limit, Total::new(total)); let layout = state.layout.clone(); let response_limit = state.byte_limits.response.get(); run_blocking(move || { let repos: Vec = page.iter() .map(|did| RepoWire { repo: did.clone(), status: "active", default_branch: open(&layout, did).ok().and_then(|repo| repo.head()).map( |head| DefaultBranchWire { name: head.name.as_str().to_string(), head: Some(head.target.to_hex()), }, ), }) .collect(); json(ListReposOut { repos, cursor }, response_limit) }) .await } #[cfg(test)] mod tests { use super::{content_disposition, rfc5987_encode}; #[test] fn content_disposition_quotes_dashes_quotes_and_adds_an_encoded_form_for_non_ascii() { let cases: &[(&str, &str)] = &[ ( "squid-main.tar.gz", "attachment; filename=\"squid-main.tar.gz\"", ), ( "squid-a\"b.tar.gz", "attachment; filename=\"squid-a-b.tar.gz\"", ), ( "squid-cafĂ©.zip", "attachment; filename=\"squid-caf-.zip\"; filename*=UTF-8''squid-caf%C3%A9.zip", ), ]; cases.iter().for_each(|(name, expected)| { assert_eq!(content_disposition(name), *expected); }); } #[test] fn rfc5987_percent_encodes_outside_the_attr_char_set() { assert_eq!(rfc5987_encode("a b:c"), "a%20b%3Ac"); assert_eq!(rfc5987_encode("plain-._~"), "plain-._~"); } }