From c4d30efd79b29bbae7fa76bfcb65c2ab8e383d44 Mon Sep 17 00:00:00 2001 From: Akshay Oppiliappan Date: Sat, 3 Oct 2026 14:10:09 +0100 Subject: [PATCH] knot2: move read response shaping into knot-wire query parsing, error bodies, wire types, patch text, mime sniffing and the per-endpoint response assembly now live in a framework-free crate. knot-xrpc handlers fetch with gix and hand off to it, so the worker can produce byte-identical responses from a different object source. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 20 + knot2/crates/knot-git/src/archive.rs | 99 +- knot2/crates/knot-git/src/patch_parse.rs | 30 +- knot2/crates/knot-gitcore/src/archive.rs | 74 + knot2/crates/knot-gitcore/src/lib.rs | 4 + knot2/crates/knot-gitcore/src/quote.rs | 28 + knot2/crates/knot-wire/Cargo.toml | 23 + knot2/crates/knot-wire/src/error.rs | 104 ++ knot2/crates/knot-wire/src/lib.rs | 8 + .../{knot-xrpc => knot-wire}/src/patchtext.rs | 6 +- knot2/crates/knot-wire/src/query.rs | 264 ++++ knot2/crates/knot-wire/src/reads.rs | 1248 ++++++++++++++++ .../{knot-xrpc => knot-wire}/src/sniff.rs | 6 +- .../{knot-xrpc => knot-wire}/src/wire.rs | 54 +- knot2/crates/knot-xrpc/Cargo.toml | 2 + knot2/crates/knot-xrpc/src/error.rs | 6 + knot2/crates/knot-xrpc/src/lib.rs | 6 +- knot2/crates/knot-xrpc/src/query.rs | 264 +--- knot2/crates/knot-xrpc/src/reads.rs | 1323 +++-------------- 19 files changed, 2012 insertions(+), 1557 deletions(-) create mode 100644 knot2/crates/knot-gitcore/src/archive.rs create mode 100644 knot2/crates/knot-gitcore/src/quote.rs create mode 100644 knot2/crates/knot-wire/Cargo.toml create mode 100644 knot2/crates/knot-wire/src/error.rs create mode 100644 knot2/crates/knot-wire/src/lib.rs rename knot2/crates/{knot-xrpc => knot-wire}/src/patchtext.rs (97%) create mode 100644 knot2/crates/knot-wire/src/query.rs create mode 100644 knot2/crates/knot-wire/src/reads.rs rename knot2/crates/{knot-xrpc => knot-wire}/src/sniff.rs (97%) rename knot2/crates/{knot-xrpc => knot-wire}/src/wire.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index 821ce8c27..b0d54fc8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4964,6 +4964,24 @@ dependencies = [ "url", ] +[[package]] +name = "knot-wire" +version = "2.0.0" +dependencies = [ + "base64", + "chrono", + "form_urlencoded", + "http", + "knot-gitcore", + "knot-types", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.11.0", + "url", +] + [[package]] name = "knot-workflow" version = "2.0.0" @@ -4997,6 +5015,7 @@ dependencies = [ "knot-events", "knot-fixtures", "knot-git", + "knot-gitcore", "knot-index", "knot-langs", "knot-lfs", @@ -5009,6 +5028,7 @@ dependencies = [ "knot-runtime", "knot-secrets", "knot-types", + "knot-wire", "serde", "serde_json", "sha2 0.11.0", diff --git a/knot2/crates/knot-git/src/archive.rs b/knot2/crates/knot-git/src/archive.rs index 0dd218df3..81815a4f7 100644 --- a/knot2/crates/knot-git/src/archive.rs +++ b/knot2/crates/knot-git/src/archive.rs @@ -2,7 +2,7 @@ use std::io::{Seek, SeekFrom, Write}; use std::sync::atomic::AtomicBool; use gix::bstr::BString; -use knot_types::{Oid, ParseError}; +use knot_types::Oid; use crate::error::{GitError, backend}; use crate::objects::MAX_TREE_DEPTH; @@ -10,90 +10,17 @@ use crate::repo::Repo; const TAR_BLOCK: u64 = 512; -knot_types::scalar_newtype! { - pub struct ArchiveLimit(u64); -} - -impl Default for ArchiveLimit { - fn default() -> Self { - Self::new(1024 * 1024 * 1024) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ArchiveFormat { - Tar, - TarGz, - Zip, -} - -impl ArchiveFormat { - fn gix(self) -> gix_archive::Format { - match self { - ArchiveFormat::Tar => gix_archive::Format::Tar, - ArchiveFormat::TarGz => gix_archive::Format::TarGz { - compression_level: None, - }, - ArchiveFormat::Zip => gix_archive::Format::Zip { - compression_level: None, - }, - } - } -} - -fn inside_root(value: String, kind: &'static str, limit: usize) -> Result { - let safe = value.len() <= limit - && !value.contains('\0') - && !value.starts_with(['/', '\\']) - && value.split(['/', '\\']).all(|component| component != ".."); - match safe { - true => Ok(value), - false => Err(ParseError::Invalid { kind, value }), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArchivePrefix(String); - -impl ArchivePrefix { - pub const MAX_BYTES: usize = 255; - - pub fn new(value: impl Into) -> Result { - inside_root(value.into(), "archive prefix", Self::MAX_BYTES).map(Self) - } - - pub fn stem(repo_name: &str, safe_ref: &str) -> Self { - let joined = format!("{repo_name}-{safe_ref}").replace(['/', '\\', '\0'], "-"); - Self( - joined - .char_indices() - .take_while(|(offset, character)| offset + character.len_utf8() <= Self::MAX_BYTES) - .map(|(_, character)| character) - .collect(), - ) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn into_tree_prefix(self) -> TreePrefix { - TreePrefix(format!("{}/", self.0)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TreePrefix(String); - -impl TreePrefix { - pub const MAX_BYTES: usize = ArchivePrefix::MAX_BYTES + 1; - - pub fn new(value: impl Into) -> Result { - inside_root(value.into(), "archive tree prefix", Self::MAX_BYTES).map(Self) - } - - pub fn as_str(&self) -> &str { - &self.0 +pub use knot_gitcore::{ArchiveFormat, ArchiveLimit, ArchivePrefix, TreePrefix}; + +fn gix_format(format: ArchiveFormat) -> gix_archive::Format { + match format { + ArchiveFormat::Tar => gix_archive::Format::Tar, + ArchiveFormat::TarGz => gix_archive::Format::TarGz { + compression_level: None, + }, + ArchiveFormat::Zip => gix_archive::Format::Zip { + compression_level: None, + }, } } @@ -133,7 +60,7 @@ impl Repo { gix::progress::Discard, &interrupt, gix_archive::Options { - format: format.gix(), + format: gix_format(format), tree_prefix: prefix.map(|prefix| BString::from(prefix.as_str())), modification_time: 0, }, diff --git a/knot2/crates/knot-git/src/patch_parse.rs b/knot2/crates/knot-git/src/patch_parse.rs index 426cd167c..046df886a 100644 --- a/knot2/crates/knot-git/src/patch_parse.rs +++ b/knot2/crates/knot-git/src/patch_parse.rs @@ -4,6 +4,7 @@ use base64::Engine; use knot_types::{AuthorName, Email, Oid}; use knot_gitcore::base85; +pub use knot_gitcore::quote_path; use crate::objects::{CommitChangeId, EntryKind}; use crate::patch::{Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES}; @@ -200,35 +201,6 @@ fn unquote(raw: &str) -> Result { } } -const PRINTABLE_ASCII: std::ops::Range = 0x20..0x7f; - -fn needs_quoting(byte: u8) -> bool { - !PRINTABLE_ASCII.contains(&byte) || matches!(byte, b'"' | b'\\') -} - -pub fn quote_path(path: &str) -> String { - match path.bytes().any(needs_quoting) { - false => path.to_string(), - true => { - let mut quoted = path.bytes().fold(String::from("\""), |mut out, byte| { - match byte { - b'\n' => out.push_str("\\n"), - b'\t' => out.push_str("\\t"), - b'"' => out.push_str("\\\""), - b'\\' => out.push_str("\\\\"), - other if !PRINTABLE_ASCII.contains(&other) => { - out.push_str(&format!("\\{other:03o}")) - } - other => out.push(other as char), - } - out - }); - quoted.push('"'); - quoted - } - } -} - fn strip_level(path: &str) -> String { path.split_once('/') .map(|(_, rest)| rest.to_string()) diff --git a/knot2/crates/knot-gitcore/src/archive.rs b/knot2/crates/knot-gitcore/src/archive.rs new file mode 100644 index 000000000..d4d3f51ee --- /dev/null +++ b/knot2/crates/knot-gitcore/src/archive.rs @@ -0,0 +1,74 @@ +use knot_types::ParseError; + +knot_types::scalar_newtype! { + pub struct ArchiveLimit(u64); +} + +impl Default for ArchiveLimit { + fn default() -> Self { + Self::new(1024 * 1024 * 1024) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveFormat { + Tar, + TarGz, + Zip, +} + +fn inside_root(value: String, kind: &'static str, limit: usize) -> Result { + let safe = value.len() <= limit + && !value.contains('\0') + && !value.starts_with(['/', '\\']) + && value.split(['/', '\\']).all(|component| component != ".."); + match safe { + true => Ok(value), + false => Err(ParseError::Invalid { kind, value }), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchivePrefix(String); + +impl ArchivePrefix { + pub const MAX_BYTES: usize = 255; + + pub fn new(value: impl Into) -> Result { + inside_root(value.into(), "archive prefix", Self::MAX_BYTES).map(Self) + } + + pub fn stem(repo_name: &str, safe_ref: &str) -> Self { + let joined = format!("{repo_name}-{safe_ref}").replace(['/', '\\', '\0'], "-"); + Self( + joined + .char_indices() + .take_while(|(offset, character)| offset + character.len_utf8() <= Self::MAX_BYTES) + .map(|(_, character)| character) + .collect(), + ) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_tree_prefix(self) -> TreePrefix { + TreePrefix(format!("{}/", self.0)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreePrefix(String); + +impl TreePrefix { + pub const MAX_BYTES: usize = ArchivePrefix::MAX_BYTES + 1; + + pub fn new(value: impl Into) -> Result { + inside_root(value.into(), "archive tree prefix", Self::MAX_BYTES).map(Self) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/knot2/crates/knot-gitcore/src/lib.rs b/knot2/crates/knot-gitcore/src/lib.rs index 3372f9516..68f903c63 100644 --- a/knot2/crates/knot-gitcore/src/lib.rs +++ b/knot2/crates/knot-gitcore/src/lib.rs @@ -1,10 +1,13 @@ +mod archive; pub mod base85; mod error; mod objects; mod patch; +mod quote; mod reads; mod refs; +pub use archive::{ArchiveFormat, ArchiveLimit, ArchivePrefix, TreePrefix}; pub use error::{CoreError, backend}; pub use objects::{ Commit, CommitChangeId, CommitRange, EntryKind, Identity, ObjectKind, ParsedTag, Tree, @@ -17,6 +20,7 @@ pub use patch::{ file_patch, is_binary, needs_blob, past_diff_budget, patch_body, side_size, subproject_line, synthesized_content, text_hunks, }; +pub use quote::quote_path; pub use reads::{ AnnotatedTag, BranchInfo, BranchTip, LastCommit, LogLimit, LogSkip, PathEntry, SizedEntry, Submodule, TagInfo, branch_tip, gitmodules, subject_line, tag_info, diff --git a/knot2/crates/knot-gitcore/src/quote.rs b/knot2/crates/knot-gitcore/src/quote.rs new file mode 100644 index 000000000..fb2ddf9a1 --- /dev/null +++ b/knot2/crates/knot-gitcore/src/quote.rs @@ -0,0 +1,28 @@ +const PRINTABLE_ASCII: std::ops::Range = 0x20..0x7f; + +fn needs_quoting(byte: u8) -> bool { + !PRINTABLE_ASCII.contains(&byte) || matches!(byte, b'"' | b'\\') +} + +pub fn quote_path(path: &str) -> String { + match path.bytes().any(needs_quoting) { + false => path.to_string(), + true => { + let mut quoted = path.bytes().fold(String::from("\""), |mut out, byte| { + match byte { + b'\n' => out.push_str("\\n"), + b'\t' => out.push_str("\\t"), + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + other if !PRINTABLE_ASCII.contains(&other) => { + out.push_str(&format!("\\{other:03o}")) + } + other => out.push(other as char), + } + out + }); + quoted.push('"'); + quoted + } + } +} diff --git a/knot2/crates/knot-wire/Cargo.toml b/knot2/crates/knot-wire/Cargo.toml new file mode 100644 index 000000000..7fe656e18 --- /dev/null +++ b/knot2/crates/knot-wire/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "knot-wire" +version = "2.0.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# xrpc request parsing and response shaping shared by knot-xrpc and the worker; +# keep this crate buildable for wasm32-unknown-unknown + +[dependencies] +knot-types = { workspace = true } +knot-gitcore = { workspace = true } +http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +url = { workspace = true } +serde_urlencoded = "0.7.1" +serde_path_to_error = "0.1.20" +form_urlencoded = "1.2.2" diff --git a/knot2/crates/knot-wire/src/error.rs b/knot2/crates/knot-wire/src/error.rs new file mode 100644 index 000000000..5bc950ec6 --- /dev/null +++ b/knot2/crates/knot-wire/src/error.rs @@ -0,0 +1,104 @@ +use http::StatusCode; + +// the error half of the xrpc wire, minus any server framework; +// knot-xrpc's XrpcError converts from this and renders through `body` +#[derive(Debug, Clone)] +pub struct WireError { + pub status: StatusCode, + pub error: &'static str, + pub message: String, +} + +impl WireError { + pub fn named(status: StatusCode, error: &'static str, message: impl Into) -> Self { + Self { + status, + error, + message: message.into(), + } + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::named(StatusCode::BAD_REQUEST, "InvalidRequest", message) + } + + pub fn auth_required(message: impl Into) -> Self { + Self::named(StatusCode::UNAUTHORIZED, "AuthenticationRequired", message) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::named(StatusCode::FORBIDDEN, "Forbidden", message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::named(StatusCode::NOT_FOUND, "NotFound", message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::named(StatusCode::CONFLICT, "Conflict", message) + } + + pub fn request_too_large(message: impl Into) -> Self { + Self::named(StatusCode::PAYLOAD_TOO_LARGE, "RequestTooLarge", message) + } + + pub fn warming(message: impl Into) -> Self { + Self::named( + StatusCode::SERVICE_UNAVAILABLE, + "ProjectionWarming", + message, + ) + } + + pub fn upstream_unavailable(message: impl Into) -> Self { + Self::named( + StatusCode::SERVICE_UNAVAILABLE, + "UpstreamUnavailable", + message, + ) + } + + pub fn overloaded(message: impl Into) -> Self { + Self::named(StatusCode::SERVICE_UNAVAILABLE, "Overloaded", message) + } + + pub fn bad_gateway(message: impl Into) -> Self { + Self::named(StatusCode::BAD_GATEWAY, "UpstreamFailure", message) + } + + pub fn internal(message: impl Into) -> Self { + Self::named(StatusCode::INTERNAL_SERVER_ERROR, "InternalError", message) + } + + pub fn body(&self) -> Vec { + error_body(self.error, &self.message) + } +} + +pub fn error_body(error: &str, message: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ "error": error, "message": message })) + .expect("a json object of two strings always serializes") +} + +impl std::fmt::Display for WireError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.error, self.message) + } +} + +// mirrors knot-xrpc's mapping of the GitError variants these share +impl From for WireError { + fn from(error: knot_gitcore::CoreError) -> Self { + use knot_gitcore::CoreError; + let message = error.to_string(); + match error { + CoreError::DepthExceeded(_) => Self::invalid_request(message), + CoreError::RevWalk(_) + | CoreError::ObjectNotFound(_) + | CoreError::Corrupt { .. } + | CoreError::ObjectType { .. } + | CoreError::Decode(_) + | CoreError::Backend(_) => Self::internal(message), + } + } +} diff --git a/knot2/crates/knot-wire/src/lib.rs b/knot2/crates/knot-wire/src/lib.rs new file mode 100644 index 000000000..dceac6a7b --- /dev/null +++ b/knot2/crates/knot-wire/src/lib.rs @@ -0,0 +1,8 @@ +pub mod error; +pub mod patchtext; +pub mod query; +pub mod reads; +pub mod sniff; +pub mod wire; + +pub use error::{WireError, error_body}; diff --git a/knot2/crates/knot-xrpc/src/patchtext.rs b/knot2/crates/knot-wire/src/patchtext.rs similarity index 97% rename from knot2/crates/knot-xrpc/src/patchtext.rs rename to knot2/crates/knot-wire/src/patchtext.rs index dfbf32168..60aa6c186 100644 --- a/knot2/crates/knot-xrpc/src/patchtext.rs +++ b/knot2/crates/knot-wire/src/patchtext.rs @@ -1,4 +1,4 @@ -use knot_git::{ +use knot_gitcore::{ BinaryDiff, Commit, EntryKind, FilePatch, Hunk, LineCount, LineNumber, LineOp, PatchBody, PatchStatus, quote_path, }; @@ -92,7 +92,7 @@ fn render_file(out: &mut String, patch: &FilePatch) { } } -pub(crate) fn render_patches(patches: &[FilePatch]) -> String { +pub fn render_patches(patches: &[FilePatch]) -> String { patches.iter().fold(String::new(), |mut out, patch| { render_file(&mut out, patch); out @@ -178,7 +178,7 @@ fn diffstat(patches: &[FilePatch]) -> String { format!("{rows}{summary}{modes}") } -pub(crate) fn render_format_patch(commit: &Commit, patches: &[FilePatch]) -> String { +pub fn render_format_patch(commit: &Commit, patches: &[FilePatch]) -> String { let subject = fold_subject(&commit.message); let body = message_body(&commit.message); let mut out = format!("From {} Mon Sep 17 00:00:00 2001\n", commit.id.to_hex()); diff --git a/knot2/crates/knot-wire/src/query.rs b/knot2/crates/knot-wire/src/query.rs new file mode 100644 index 000000000..9557dbba5 --- /dev/null +++ b/knot2/crates/knot-wire/src/query.rs @@ -0,0 +1,264 @@ +use knot_types::{OwnerDid, RepoDid, RepoPath, RepoRkey}; +use serde::de::{self, Deserialize, DeserializeOwned, Deserializer}; + +use crate::error::WireError; + +// same path axum's Query takes, so a malformed query reads the same on either knot +pub fn parse_query(query: &str) -> Result { + let deserializer = + serde_urlencoded::Deserializer::new(form_urlencoded::parse(query.as_bytes())); + serde_path_to_error::deserialize(deserializer).map_err(|error| { + WireError::invalid_request(format!("Failed to deserialize query string: {error}")) + }) +} + +// Each route's default and its limit are included in the type, +// such that a limit that is ok on one endpoint +// can't be spent on another one with a lower roof. +#[derive(Clone, Copy)] +pub struct Limit(usize); + +impl Limit { + pub fn get(self) -> usize { + self.0 + } +} + +impl Default for Limit { + fn default() -> Self { + Limit(DEFAULT) + } +} + +impl<'de, const DEFAULT: usize, const MAX: usize> Deserialize<'de> for Limit { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + if raw.is_empty() { + return Ok(Limit(DEFAULT)); + } + let value = raw + .parse::() + .map_err(|_| de::Error::custom("limit must be an integer"))?; + Ok(Limit(usize::try_from(value).unwrap_or(0).min(MAX).max(1))) + } +} + +knot_types::scalar_newtype! { + #[derive(Default)] + pub struct Offset(usize); +} + +impl<'de> Deserialize<'de> for Offset { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + if raw.is_empty() { + return Ok(Offset::new(0)); + } + raw.parse::() + .map(Offset::new) + .map_err(|_| de::Error::custom("cursor must be an integer")) + } +} + +knot_types::scalar_newtype! { + pub struct Total(usize); +} + +pub fn next_cursor( + offset: Offset, + limit: Limit, + total: Total, +) -> Option { + offset + .get() + .checked_add(limit.get()) + .filter(|&end| end < total.get()) + .map(|end| end.to_string()) +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum Order { + #[default] + Desc, + Asc, +} + +impl Order { + pub fn descending(self) -> bool { + matches!(self, Order::Desc) + } +} + +impl<'de> Deserialize<'de> for Order { + fn deserialize>(deserializer: D) -> Result { + match String::deserialize(deserializer)?.as_str() { + "" | "desc" => Ok(Order::Desc), + "asc" => Ok(Order::Asc), + _ => Err(de::Error::custom("order must be 'asc' or 'desc'")), + } + } +} + +pub enum RepoArg { + Did(RepoDid), + OwnerRkey { owner: OwnerDid, rkey: RepoRkey }, +} + +impl RepoArg { + pub fn basename(&self) -> &str { + match self { + RepoArg::Did(did) => did.as_str(), + RepoArg::OwnerRkey { rkey, .. } => rkey.as_str(), + } + } + + pub fn to_param(&self) -> String { + match self { + RepoArg::Did(did) => did.as_str().to_string(), + RepoArg::OwnerRkey { owner, rkey } => format!("{}/{}", owner.as_str(), rkey.as_str()), + } + } +} + +impl<'de> Deserialize<'de> for RepoArg { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + if !raw.starts_with("did:") { + return Err(de::Error::custom( + "missing or invalid repo parameter, expected repo DID", + )); + } + Ok(match raw.split_once('/') { + None => RepoArg::Did(RepoDid::new(raw).map_err(de::Error::custom)?), + Some((owner, rkey)) => RepoArg::OwnerRkey { + owner: OwnerDid::new(owner).map_err(de::Error::custom)?, + rkey: RepoRkey::new(rkey).map_err(de::Error::custom)?, + }, + }) + } +} + +const MAX_REVSPEC_BYTES: usize = 4096; + +#[derive(Clone, Default)] +pub struct Revspec(String); + +impl Revspec { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for Revspec { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + match raw.len() <= MAX_REVSPEC_BYTES && !raw.chars().any(char::is_control) { + true => Ok(Self(raw)), + false => Err(de::Error::custom("invalid revision")), + } + } +} + +#[derive(Default)] +pub struct BranchArg(Option); + +impl BranchArg { + pub fn get(&self) -> Option<&knot_types::BranchName> { + self.0.as_ref() + } +} + +impl<'de> serde::Deserialize<'de> for BranchArg { + fn deserialize>(deserializer: D) -> Result { + match String::deserialize(deserializer)? { + raw if raw.is_empty() => Ok(Self(None)), + raw => knot_types::BranchName::new(raw) + .map(|name| Self(Some(name))) + .map_err(de::Error::custom), + } + } +} + +#[derive(Default)] +pub struct TagArg(Option); + +impl TagArg { + pub fn get(&self) -> Option<&knot_types::TagName> { + self.0.as_ref() + } +} + +impl<'de> serde::Deserialize<'de> for TagArg { + fn deserialize>(deserializer: D) -> Result { + match String::deserialize(deserializer)? { + raw if raw.is_empty() => Ok(Self(None)), + raw => { + let short = raw.strip_prefix("refs/tags/").unwrap_or(&raw); + knot_types::TagName::new(short) + .map(|name| Self(Some(name))) + .map_err(de::Error::custom) + } + } + } +} + +#[derive(Default)] +pub enum TreePath { + #[default] + Root, + At(RepoPath), + Outside(String), +} + +impl TreePath { + pub fn as_str(&self) -> &str { + match self { + TreePath::Root => "", + TreePath::At(path) => path.as_str(), + TreePath::Outside(raw) => raw, + } + } + + pub fn dir(&self) -> Option> { + match self { + TreePath::Root => Some(None), + TreePath::At(path) => Some(Some(path)), + TreePath::Outside(_) => None, + } + } + + pub fn file(&self) -> Option<&RepoPath> { + match self { + TreePath::At(path) => Some(path), + TreePath::Root | TreePath::Outside(_) => None, + } + } +} + +impl<'de> Deserialize<'de> for TreePath { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Ok(match raw.is_empty() { + true => TreePath::Root, + false => match RepoPath::new(&raw) { + Ok(path) => TreePath::At(path), + Err(_) => TreePath::Outside(raw), + }, + }) + } +} + +#[derive(Default)] +pub struct RawFlag(bool); + +impl RawFlag { + pub fn requested(&self) -> bool { + self.0 + } +} + +impl<'de> Deserialize<'de> for RawFlag { + fn deserialize>(deserializer: D) -> Result { + Ok(RawFlag(String::deserialize(deserializer)? == "true")) + } +} diff --git a/knot2/crates/knot-wire/src/reads.rs b/knot2/crates/knot-wire/src/reads.rs new file mode 100644 index 000000000..07e90c15d --- /dev/null +++ b/knot2/crates/knot-wire/src/reads.rs @@ -0,0 +1,1248 @@ +use std::collections::{BTreeMap, HashMap}; + +use http::StatusCode; +use serde::de::{self, Deserializer}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use knot_gitcore::{ + ArchiveFormat, ArchivePrefix, BranchInfo, Commit, FilePatch, LastCommit, SizedEntry, + Submodule, TagInfo, screens_reserved, +}; +use knot_types::{AuthorName, Email, Oid, OwnerDid, RepoDid, RepoRkey}; + +use crate::error::WireError; +use crate::patchtext::{render_format_patch, render_patches}; +use crate::query::{ + BranchArg, Limit, Offset, Order, RawFlag, RepoArg, Revspec, TagArg, Total, TreePath, + next_cursor, +}; +use crate::sniff; +use crate::wire::{ + BranchWire, CommitWire, FileWire, FormatPatchWire, NiceDiffWire, PatchIdentityWire, TagWire, + ZERO_TIME, fold_subject, message_body, nice_diff, normalize_message_section, rfc2822, rfc3339, +}; + +pub const DEFAULT_PAGE: usize = 50; +pub const MAX_PAGE: usize = 100; +pub const LIST_REFS_DEFAULT: usize = 100; +pub const LIST_REFS_MAX: usize = 1000; +pub const LIST_REPOS_DEFAULT: usize = 50; +pub const LIST_REPOS_MAX: usize = 1000; +pub const MAX_BLOB_BYTES: u64 = 25 * 1024 * 1024; +pub const MAX_COMPARE_COMMITS: usize = 500; +pub const RAW_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +pub fn repo_not_found() -> WireError { + WireError::named( + StatusCode::NOT_FOUND, + "RepoNotFound", + "repository not found on this knot", + ) +} + +pub fn ref_not_found() -> WireError { + WireError::named( + StatusCode::NOT_FOUND, + "RefNotFound", + "git reference not found", + ) +} + +pub fn blob_too_large() -> WireError { + WireError::named( + StatusCode::PAYLOAD_TOO_LARGE, + "BlobTooLarge", + "file is too large to serve", + ) +} + +pub fn warming() -> WireError { + WireError::warming("registry projection is still warming") +} + +pub fn path_not_found() -> WireError { + WireError::named( + StatusCode::NOT_FOUND, + "PathNotFound", + "path not found in repository tree", + ) +} + +pub fn file_not_found() -> WireError { + WireError::named( + StatusCode::NOT_FOUND, + "FileNotFound", + "file not found at specified path", + ) +} + +pub fn branch_not_found() -> WireError { + WireError::named(StatusCode::NOT_FOUND, "BranchNotFound", "branch not found") +} + +pub fn tag_not_found() -> WireError { + WireError::named(StatusCode::BAD_REQUEST, "TagNotFound", "tag not found") +} + +pub fn revision_not_found(rev: &str) -> WireError { + WireError::named( + StatusCode::BAD_REQUEST, + "RevisionNotFound", + format!("error resolving revision {rev}"), + ) +} + +pub fn compare_error(error: impl std::fmt::Display) -> WireError { + WireError::named( + StatusCode::BAD_REQUEST, + "CompareError", + format!("error comparing revisions: {error}"), + ) +} + +pub fn compare_too_long() -> WireError { + WireError::named( + StatusCode::BAD_REQUEST, + "CompareError", + format!("comparison spans more than maximum of {MAX_COMPARE_COMMITS} commits"), + ) +} + +pub fn default_branch_missing() -> WireError { + WireError::named( + StatusCode::INTERNAL_SERVER_ERROR, + "InvalidRequest", + "failed to get default branch", + ) +} + +pub fn raw_forbidden() -> WireError { + WireError::named( + StatusCode::FORBIDDEN, + "InvalidRequest", + "only image, video, and text files can be accessed directly", + ) +} + +pub fn archive_error(error: impl std::fmt::Display) -> WireError { + WireError::named( + StatusCode::BAD_REQUEST, + "ArchiveError", + format!("failed to create archive: {error}"), + ) +} + +pub 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), + } +} + +pub fn readme_serving_limit(response_limit: usize) -> u64 { + response_limit as u64 / 8 +} + +pub fn names_reserved(refspec: &str) -> bool { + screens_reserved(refspec) || screens_reserved(&format!("refs/{refspec}")) +} + +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(()) + } +} + +// body of a 200 application/json response +pub fn json(value: impl Serialize, limit: usize) -> Result, WireError> { + let mut writer = LimitWriter { + buf: Vec::new(), + limit, + }; + match serde_json::to_writer(&mut writer, &value) { + Ok(()) => Ok(writer.buf), + Err(error) if error.is_io() => Err(WireError::request_too_large( + "response exceeds configured maximum size", + )), + Err(error) => Err(WireError::internal(format!( + "failed to serialize response: {error}" + ))), + } +} + +#[derive(Deserialize)] +pub struct TreeParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, + #[serde(default)] + pub path: TreePath, +} + +#[derive(Serialize)] +pub struct SignatureOut { + pub name: AuthorName, + pub email: Email, + pub when: String, +} + +#[derive(Serialize)] +pub struct LastCommitOut { + pub hash: Oid, + pub message: String, + pub when: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +impl LastCommitOut { + pub fn of(last: &LastCommit, author: Option<&Commit>) -> Self { + Self { + hash: last.id, + message: last.subject.clone(), + when: rfc3339(last.time.get(), 0), + author: author.map(|commit| SignatureOut { + name: commit.author.name.clone(), + email: commit.author.email.clone(), + when: String::new(), + }), + } + } +} + +#[derive(Serialize)] +pub struct TreeEntryOut { + pub name: String, + pub mode: String, + pub size: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_commit: Option, +} + +#[derive(Serialize)] +pub struct ReadmeOut { + pub filename: String, + pub contents: String, +} + +impl ReadmeOut { + pub fn empty() -> Self { + Self { + filename: String::new(), + contents: String::new(), + } + } +} + +#[derive(Serialize)] +pub struct TreeOut { + #[serde(rename = "ref")] + pub refspec: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dotdot: Option, + pub files: Vec, + #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] + pub last_commit: Option, + pub readme: ReadmeOut, +} + +pub 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('.'))) +} + +pub fn newest(attributed: &HashMap) -> Option<&LastCommit> { + attributed.values().max_by_key(|last| (last.time, last.id)) +} + +pub fn tree_out( + refspec: &Revspec, + path: &TreePath, + entries: &[SizedEntry], + attributed: &HashMap, + newest_commit: Option<&Commit>, + readme: ReadmeOut, +) -> TreeOut { + let path = path.as_str(); + 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::of(last, None)), + }) + .collect(); + let last_commit = newest(attributed).map(|last| LastCommitOut::of(last, newest_commit)); + let parent = (!path.is_empty()).then(|| path.to_string()); + let dotdot = (!path.is_empty()) + .then(|| path.rsplit_once('/').map(|(parent, _)| parent.to_string())) + .flatten(); + TreeOut { + refspec: refspec.as_str().to_string(), + parent, + dotdot, + files, + last_commit, + readme, + } +} + +#[derive(Deserialize)] +pub struct LogParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, + #[serde(default)] + pub path: TreePath, + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub cursor: Offset, +} + +#[derive(Serialize)] +pub struct LogOut { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub commits: Vec, + #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] + pub refspec: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + pub log: bool, + #[serde(skip_serializing_if = "is_zero")] + pub total: usize, + pub page: usize, + pub per_page: usize, +} + +fn is_zero(value: &usize) -> bool { + *value == 0 +} + +pub fn log_out(params: &LogParams, commits: &[Commit], total: usize) -> LogOut { + let offset = params.cursor.get(); + let limit = params.limit.get(); + 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, + } +} + +#[derive(Deserialize)] +pub struct BranchesParams { + pub repo: RepoArg, + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub cursor: Offset, +} + +#[derive(Serialize)] +pub struct BranchesOut { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub branches: Vec, +} + +pub fn short_default(default: Option<&str>) -> Option { + default.map(|name| name.trim_start_matches("refs/heads/").to_string()) +} + +pub fn branches_out( + params: &BranchesParams, + mut branches: Vec, + default_ref: Option<&str>, + absent: Oid, +) -> BranchesOut { + branches.sort_by(|a, b| { + b.tip + .created_at() + .cmp(&a.tip.created_at()) + .then_with(|| a.name.cmp(&b.name)) + }); + let default = short_default(default_ref); + let window: Vec = branches + .iter() + .skip(params.cursor.get()) + .take(params.limit.get()) + .map(|branch| { + BranchWire::of( + branch, + default.as_deref() == Some(branch.name.as_str()), + absent, + ) + }) + .rev() + .collect(); + BranchesOut { branches: window } +} + +#[derive(Deserialize)] +pub struct BranchParams { + pub repo: RepoArg, + #[serde(default)] + pub name: BranchArg, +} + +#[derive(Serialize)] +pub struct BranchOut { + pub name: String, + pub hash: String, + #[serde(rename = "shortHash")] + pub short_hash: String, + pub when: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub author: SignatureOut, + #[serde(rename = "isDefault")] + pub is_default: bool, +} + +pub fn missing_name() -> WireError { + WireError::invalid_request("missing name parameter") +} + +pub fn branch_out( + name: &knot_types::BranchName, + target: Oid, + commit: &Commit, + default_ref: Option<&str>, +) -> BranchOut { + let default = short_default(default_ref); + let hash = target.to_hex(); + 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()), + } +} + +#[derive(Deserialize)] +pub struct TagsParams { + pub repo: RepoArg, + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub cursor: Offset, +} + +#[derive(Serialize)] +pub struct TagsOut { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +pub fn tags_out(params: &TagsParams, mut tags: Vec) -> TagsOut { + tags.sort_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.name.cmp(&b.name)) + }); + TagsOut { + tags: tags + .iter() + .skip(params.cursor.get()) + .take(params.limit.get()) + .map(TagWire::of) + .collect(), + } +} + +#[derive(Deserialize)] +pub struct TagParams { + pub repo: RepoArg, + #[serde(default)] + pub tag: TagArg, +} + +#[derive(Serialize)] +pub struct TagOut { + pub tag: TagWire, +} + +pub fn missing_tag() -> WireError { + WireError::invalid_request("missing tag parameter") +} + +pub fn tag_out(name: &knot_types::TagName, tags: Vec) -> Result { + let info = tags + .into_iter() + .find(|tag| &tag.name == name) + .ok_or_else(tag_not_found)?; + Ok(TagOut { + tag: TagWire::of(&info), + }) +} + +#[derive(Deserialize)] +pub struct BlobParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, + #[serde(default)] + pub path: TreePath, + #[serde(default)] + pub raw: RawFlag, +} + +#[derive(Serialize)] +pub struct SubmoduleOut { + pub name: String, + pub url: String, + pub branch: String, +} + +#[derive(Serialize)] +pub struct BlobOut { + #[serde(rename = "ref")] + pub refspec: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(rename = "isBinary", skip_serializing_if = "Option::is_none")] + pub is_binary: Option, + #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] + pub mime_type: Option<&'static str>, + #[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")] + pub last_commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub submodule: Option, +} + +pub fn missing_path() -> WireError { + WireError::invalid_request("missing path parameter") +} + +pub fn submodule_at<'a>(submodules: &'a [Submodule], path: &str) -> Option<&'a Submodule> { + submodules + .iter() + .find(|submodule| submodule.path.as_str() == path) +} + +pub fn submodule_out(params: &BlobParams, submodule: &Submodule) -> BlobOut { + BlobOut { + refspec: params.refspec.as_str().to_string(), + path: params.path.as_str().to_string(), + content: None, + encoding: None, + size: None, + is_binary: None, + mime_type: None, + last_commit: None, + submodule: Some(SubmoduleOut { + name: submodule.name.clone(), + url: submodule.url.clone(), + branch: submodule + .branch + .as_ref() + .map(|branch| branch.to_string()) + .unwrap_or_default(), + }), + } +} + +pub fn blob_mime(path: &str, contents: &[u8]) -> &'static str { + sniff::override_by_extension(path, sniff::detect_content_type(contents)) +} + +pub fn blob_out( + params: &BlobParams, + mime: &'static str, + contents: &[u8], + last_commit: Option, +) -> BlobOut { + let is_binary = !sniff::is_textual_mime(mime); + 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"), + }; + BlobOut { + refspec: params.refspec.as_str().to_string(), + path: params.path.as_str().to_string(), + content: Some(content), + encoding: Some(encoding), + size: Some(contents.len() as i64), + is_binary: Some(is_binary), + mime_type: Some(mime), + last_commit, + submodule: None, + } +} + +// a framework-free response: knot-xrpc and the worker each turn this into their own type +pub struct RawResponse { + pub status: StatusCode, + pub headers: Vec<(http::HeaderName, String)>, + pub body: Vec, +} + +pub fn etag_matches<'a>(if_none_match: impl Iterator, etag: &str) -> bool { + if_none_match + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|candidate| { + candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag + }) +} + +pub fn quoted_etag(digest: &[u8]) -> String { + format!("\"{}\"", knot_types::lowercase_hex(digest)) +} + +pub fn serve_raw<'a>( + if_none_match: impl Iterator, + mime: &'static str, + contents: Vec, +) -> Result { + use http::header; + if mime.starts_with("image/") || mime.starts_with("video/") { + let etag = quoted_etag(&Sha256::digest(&contents)); + if etag_matches(if_none_match, &etag) { + return Ok(RawResponse { + status: StatusCode::NOT_MODIFIED, + headers: Vec::new(), + body: Vec::new(), + }); + } + return Ok(RawResponse { + status: StatusCode::OK, + headers: vec![ + (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()), + ], + body: contents, + }); + } + if sniff::is_textual_mime(mime) { + return Ok(RawResponse { + status: StatusCode::OK, + headers: vec![ + (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()), + ], + body: contents, + }); + } + Err(raw_forbidden()) +} + +#[derive(Deserialize)] +pub struct DiffParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, +} + +#[derive(Serialize)] +pub struct DiffOut { + #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] + pub refspec: String, + pub diff: NiceDiffWire, +} + +pub fn diff_out(params: &DiffParams, commit: &Commit, patches: &[FilePatch]) -> DiffOut { + DiffOut { + refspec: params.refspec.as_str().to_string(), + diff: nice_diff(commit, patches), + } +} + +#[derive(Deserialize)] +pub struct CompareParams { + pub repo: RepoArg, + #[serde(default)] + pub rev1: Revspec, + #[serde(default)] + pub rev2: Revspec, +} + +impl CompareParams { + pub fn revs(&self) -> Result<(String, String), WireError> { + let rev1 = self.rev1.as_str().to_string(); + if rev1.is_empty() { + return Err(WireError::invalid_request("missing rev1 parameter")); + } + let rev2 = self.rev2.as_str().to_string(); + if rev2.is_empty() { + return Err(WireError::invalid_request("missing rev2 parameter")); + } + Ok((rev1, rev2)) + } +} + +#[derive(Serialize)] +pub struct CompareOut { + pub rev1: String, + pub rev2: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub merge_base: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub format_patch: Vec, + #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] + pub patch_raw: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub combined_patch: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub combined_patch_raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub binary_omitted: Option, +} + +pub fn format_patch_entry(commit: &Commit, patches: &[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() +} + +// the commits a compare renders: oldest first, merges dropped +pub fn compare_series(between: Vec) -> Vec { + between + .into_iter() + .rev() + .filter(|commit| commit.parents.len() <= 1) + .collect() +} + +pub fn series_entry(commit: &Commit, patches: &[FilePatch]) -> (FormatPatchWire, String) { + let raw = render_format_patch(commit, patches); + (format_patch_entry(commit, patches, &raw), raw) +} + +pub fn compare_out( + base: Oid, + head: Oid, + merge_base: Option, + entries: Vec<(FormatPatchWire, String)>, + combined: Option>, + binary_omitted: bool, +) -> CompareOut { + let patch_raw: String = entries + .iter() + .flat_map(|(_, raw)| [raw.as_str(), "\n"]) + .collect(); + let (combined_patch, combined_patch_raw) = match combined { + Some(patches) => ( + Some(patches.iter().map(FileWire::of).collect::>()), + Some(render_patches(&patches)), + ), + None => (None, None), + }; + CompareOut { + rev1: base.to_hex(), + rev2: head.to_hex(), + merge_base: merge_base.map(|oid| oid.to_hex()), + format_patch: entries.into_iter().map(|(entry, _)| entry).collect(), + patch_raw, + combined_patch, + combined_patch_raw, + binary_omitted: binary_omitted.then_some(true), + } +} + +#[derive(Clone, Copy)] +pub struct ArchiveFormatArg(pub ArchiveFormat); + +impl Default for ArchiveFormatArg { + fn default() -> Self { + ArchiveFormatArg(ArchiveFormat::TarGz) + } +} + +impl ArchiveFormatArg { + pub fn format(self) -> ArchiveFormat { + self.0 + } + + pub fn name(self) -> &'static str { + match self.0 { + ArchiveFormat::Zip => "zip", + _ => "tar.gz", + } + } + + pub 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)] +pub struct ArchivePrefixArg(pub Option); + +impl<'de> Deserialize<'de> for ArchivePrefixArg { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + let cleaned = raw + .split('/') + .filter(|component| !component.is_empty() && *component != ".") + .collect::>() + .join("/"); + match cleaned.is_empty() { + true => Ok(Self(None)), + false => ArchivePrefix::new(cleaned) + .ok() + .filter(|prefix| { + !prefix.as_str().contains('\\') && !prefix.as_str().contains(char::is_control) + }) + .map(|prefix| Self(Some(prefix))) + .ok_or_else(|| { + de::Error::custom(format!( + "archive prefix must stay inside the archive root within {} bytes, and mustn't contain a backslash or a control character", + ArchivePrefix::MAX_BYTES + )) + }), + } + } +} + +#[derive(Deserialize)] +pub struct ArchiveParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, + #[serde(default)] + pub format: ArchiveFormatArg, + #[serde(default)] + pub prefix: ArchivePrefixArg, +} + +pub fn short_ref(refspec: &str) -> &str { + ["refs/heads/", "refs/tags/", "refs/remotes/", "refs/"] + .into_iter() + .find_map(|prefix| refspec.strip_prefix(prefix)) + .unwrap_or(refspec) +} + +fn sanitize_filename(name: &str) -> String { + name.replace( + |c: char| c.is_ascii_control() || matches!(c, '"' | '\\' | '/'), + "-", + ) +} + +pub 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() +} + +pub 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) + ), + } +} + +pub 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()) +} + +pub fn archive_link( + service_url: &str, + params: &ArchiveParams, + prefix: &ArchivePrefix, + resolved: Oid, +) -> String { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("format", params.format.name()); + query.append_pair("prefix", prefix.as_str()); + query.append_pair("ref", &resolved.to_hex()); + query.append_pair("repo", ¶ms.repo.to_param()); + format!( + "<{service_url}/xrpc/sh.tangled.repo.archive?{}>; rel=\"immutable\"", + query.finish() + ) +} + +// the prefix an archive is rooted at when the caller doesn't pick one +pub fn archive_prefix(params: &ArchiveParams, registered_rkey: Option<&RepoRkey>) -> ArchivePrefix { + match ¶ms.prefix.0 { + Some(prefix) => prefix.clone(), + None => { + let name = match registered_rkey { + Some(rkey) => rkey.as_str(), + None => params.repo.basename(), + }; + ArchivePrefix::stem(name, short_ref(params.refspec.as_str())) + } + } +} + +#[derive(Deserialize)] +pub struct LanguagesParams { + pub repo: RepoArg, + #[serde(rename = "ref", default)] + pub refspec: Revspec, +} + +#[derive(Serialize)] +pub struct LanguageOut { + pub name: knot_types::LanguageName, + pub size: knot_types::LanguageBytes, + pub percentage: i64, +} + +#[derive(Serialize)] +pub struct LanguagesOut { + #[serde(rename = "ref")] + pub refspec: String, + pub languages: Option>, + #[serde(rename = "totalSize", skip_serializing_if = "Option::is_none")] + pub total_size: Option, + #[serde(rename = "totalFiles", skip_serializing_if = "Option::is_none")] + pub total_files: Option, +} + +pub fn languages_out( + params: &LanguagesParams, + sizes: &HashMap, +) -> LanguagesOut { + 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; + 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), + } +} + +#[derive(Deserialize)] +pub struct DefaultBranchParams { + pub repo: RepoArg, +} + +#[derive(Serialize)] +pub struct DefaultBranchOut { + pub name: String, + pub hash: String, + pub when: String, +} + +pub fn default_branch_out(default_ref: Option<&str>) -> Result { + let name = short_default(default_ref).ok_or_else(default_branch_missing)?; + Ok(DefaultBranchOut { + name, + hash: String::new(), + when: rfc3339(0, 0), + }) +} + +#[derive(Deserialize)] +pub struct DescribeRepoParams { + #[serde(rename = "repoDid")] + pub repo_did: RepoDid, +} + +#[derive(Serialize)] +pub struct DescribeRepoOut { + #[serde(rename = "repoDid")] + pub repo_did: RepoDid, + #[serde(rename = "ownerDid")] + pub owner_did: OwnerDid, + pub rkey: RepoRkey, +} + +#[derive(Serialize)] +pub struct DefaultBranchWire { + #[serde(rename = "ref")] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub head: Option, +} + +#[derive(Deserialize)] +pub struct ListRefsParams { + pub repo: RepoArg, + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub cursor: Offset, +} + +#[derive(Serialize)] +pub struct RefWire { + #[serde(rename = "ref")] + pub name: String, + pub sha: Oid, +} + +#[derive(Serialize)] +pub struct ListRefsOut { + pub refs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] + pub default_branch: Option, +} + +// `refs` is every reference the repo holds, `head` its resolved HEAD +pub fn list_refs_out( + params: &ListRefsParams, + refs: &[(knot_types::RefName, Oid)], + head: Option<(&str, Oid)>, +) -> ListRefsOut { + let mut public: Vec<&(knot_types::RefName, Oid)> = refs + .iter() + .filter(|(name, _)| knot_gitcore::is_public_ref(name)) + .collect(); + public.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + let total = public.len(); + let window: Vec = public + .iter() + .skip(params.cursor.get()) + .take(params.limit.get()) + .map(|(name, target)| RefWire { + name: name.as_str().to_string(), + sha: *target, + }) + .collect(); + ListRefsOut { + refs: window, + cursor: next_cursor(params.cursor, params.limit, Total::new(total)), + default_branch: head.map(|(name, target)| DefaultBranchWire { + name: name.to_string(), + head: Some(target.to_hex()), + }), + } +} + +#[derive(Deserialize)] +pub struct ListReposParams { + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub cursor: Offset, + #[serde(default)] + pub order: Order, +} + +#[derive(Serialize)] +pub struct RepoWire { + pub repo: RepoDid, + pub status: &'static str, + #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] + pub default_branch: Option, +} + +#[derive(Serialize)] +pub struct ListReposOut { + pub repos: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// the page of hosted repos to describe, plus the cursor past it +pub fn list_repos_page( + params: &ListReposParams, + mut repos: Vec, +) -> (Vec, Option) { + if params.order.descending() { + repos.reverse(); + } + let total = repos.len(); + let page = repos + .into_iter() + .skip(params.cursor.get()) + .take(params.limit.get()) + .collect(); + (page, next_cursor(params.cursor, params.limit, Total::new(total))) +} + +pub fn repo_wire(did: RepoDid, head: Option<(&str, Oid)>) -> RepoWire { + RepoWire { + repo: did, + status: "active", + default_branch: head.map(|(name, target)| DefaultBranchWire { + name: name.to_string(), + head: Some(target.to_hex()), + }), + } +} + +#[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-._~"); + } +} diff --git a/knot2/crates/knot-xrpc/src/sniff.rs b/knot2/crates/knot-wire/src/sniff.rs similarity index 97% rename from knot2/crates/knot-xrpc/src/sniff.rs rename to knot2/crates/knot-wire/src/sniff.rs index 039c684f5..b2eaab48a 100644 --- a/knot2/crates/knot-xrpc/src/sniff.rs +++ b/knot2/crates/knot-wire/src/sniff.rs @@ -201,7 +201,7 @@ const SIGNATURES: &[Sig] = &[ Sig::Text, ]; -pub(crate) fn detect_content_type(content: &[u8]) -> &'static str { +pub fn detect_content_type(content: &[u8]) -> &'static str { let data = &content[..content.len().min(SNIFF_LIMIT)]; let first_non_ws = data .iter() @@ -213,7 +213,7 @@ pub(crate) fn detect_content_type(content: &[u8]) -> &'static str { .unwrap_or("application/octet-stream") } -pub(crate) fn override_by_extension(path: &str, detected: &'static str) -> &'static str { +pub fn override_by_extension(path: &str, detected: &'static str) -> &'static str { let extension = path.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); match extension.to_ascii_lowercase().as_str() { "svg" => "image/svg+xml", @@ -224,7 +224,7 @@ pub(crate) fn override_by_extension(path: &str, detected: &'static str) -> &'sta } } -pub(crate) fn is_textual_mime(mime: &str) -> bool { +pub fn is_textual_mime(mime: &str) -> bool { mime.starts_with("text/") || matches!( mime, diff --git a/knot2/crates/knot-xrpc/src/wire.rs b/knot2/crates/knot-wire/src/wire.rs similarity index 93% rename from knot2/crates/knot-xrpc/src/wire.rs rename to knot2/crates/knot-wire/src/wire.rs index 1b3156ddb..1af018b72 100644 --- a/knot2/crates/knot-xrpc/src/wire.rs +++ b/knot2/crates/knot-wire/src/wire.rs @@ -3,13 +3,13 @@ use base64::engine::general_purpose::STANDARD; use serde::ser::SerializeSeq; use serde::{Serialize, Serializer}; -use knot_git::{ +use knot_gitcore::{ BranchInfo, BranchTip, Commit, CommitChangeId, EntryKind, FilePatch, Hunk, Identity, LineOp, PatchStatus, TagInfo, }; use knot_types::{AuthorName, Email, Oid, TagName}; -pub(crate) const ZERO_TIME: &str = "0001-01-01T00:00:00Z"; +pub const ZERO_TIME: &str = "0001-01-01T00:00:00Z"; fn display_opt( value: &Option, @@ -29,15 +29,15 @@ fn zoned(seconds: i64, offset_seconds: i32) -> chrono::DateTime String { +pub fn rfc3339(seconds: i64, offset_seconds: i32) -> String { zoned(seconds, offset_seconds).to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -pub(crate) fn rfc2822(seconds: i64, offset_seconds: i32) -> String { +pub fn rfc2822(seconds: i64, offset_seconds: i32) -> String { zoned(seconds, offset_seconds).to_rfc2822() } -pub(crate) struct HashBytes(pub Oid); +pub struct HashBytes(pub Oid); impl Serialize for HashBytes { fn serialize(&self, serializer: S) -> Result { @@ -51,7 +51,7 @@ impl Serialize for HashBytes { } } -pub(crate) struct Base64Bytes(Vec); +pub struct Base64Bytes(Vec); impl Serialize for Base64Bytes { fn serialize(&self, serializer: S) -> Result { @@ -60,7 +60,7 @@ impl Serialize for Base64Bytes { } #[derive(Serialize)] -pub(crate) struct SignatureWire { +pub struct SignatureWire { #[serde(rename = "Name")] pub name: AuthorName, #[serde(rename = "Email")] @@ -96,7 +96,7 @@ impl SignatureWire { } #[derive(Serialize)] -pub(crate) struct CommitWire { +pub struct CommitWire { pub hash: HashBytes, pub author: SignatureWire, pub committer: SignatureWire, @@ -145,7 +145,7 @@ impl CommitWire { } #[derive(Serialize)] -pub(crate) struct BranchCommitWire { +pub struct BranchCommitWire { #[serde(rename = "Hash")] pub hash: HashBytes, #[serde(rename = "Author")] @@ -169,13 +169,13 @@ pub(crate) struct BranchCommitWire { } #[derive(Serialize)] -pub(crate) struct Reference { +pub struct Reference { pub name: String, pub hash: Oid, } #[derive(Serialize)] -pub(crate) struct BranchWire { +pub struct BranchWire { pub reference: Reference, pub commit: BranchCommitWire, #[serde(skip_serializing_if = "std::ops::Not::not")] @@ -221,7 +221,7 @@ impl BranchWire { } #[derive(Serialize)] -pub(crate) struct TagObjectWire { +pub struct TagObjectWire { #[serde(rename = "Hash")] pub hash: HashBytes, #[serde(rename = "Name")] @@ -239,7 +239,7 @@ pub(crate) struct TagObjectWire { } #[derive(Serialize)] -pub(crate) struct TagWire { +pub struct TagWire { pub name: TagName, pub hash: Oid, #[serde(skip_serializing_if = "Option::is_none")] @@ -278,7 +278,7 @@ impl TagWire { } } -pub(crate) fn fold_subject(message: &str) -> String { +pub fn fold_subject(message: &str) -> String { message .split("\n\n") .next() @@ -290,7 +290,7 @@ pub(crate) fn fold_subject(message: &str) -> String { .join(" ") } -pub(crate) fn message_body(message: &str) -> String { +pub fn message_body(message: &str) -> String { message .split_once("\n\n") .map(|(_, body)| body.trim_matches('\n').to_string()) @@ -308,7 +308,7 @@ fn recombine_message(message: &str) -> String { } #[derive(Serialize)] -pub(crate) struct LineWire { +pub struct LineWire { #[serde(rename = "Op")] pub op: u8, #[serde(rename = "Line")] @@ -316,7 +316,7 @@ pub(crate) struct LineWire { } #[derive(Serialize)] -pub(crate) struct TextFragmentWire { +pub struct TextFragmentWire { #[serde(rename = "Comment")] pub comment: String, #[serde(rename = "OldPosition")] @@ -375,13 +375,13 @@ impl TextFragmentWire { } #[derive(Serialize)] -pub(crate) struct DiffNameWire { +pub struct DiffNameWire { pub old: String, pub new: String, } #[derive(Serialize)] -pub(crate) struct DiffWire { +pub struct DiffWire { pub name: DiffNameWire, pub text_fragments: Option>, pub is_binary: bool, @@ -417,20 +417,20 @@ impl DiffWire { } #[derive(Serialize)] -pub(crate) struct DiffStatWire { +pub struct DiffStatWire { pub insertions: i64, pub deletions: i64, pub files_changed: i64, } #[derive(Serialize)] -pub(crate) struct NiceDiffWire { +pub struct NiceDiffWire { pub commit: CommitWire, pub stat: DiffStatWire, pub diff: Option>, } -pub(crate) fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire { +pub fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire { let diffs: Vec = patches.iter().map(DiffWire::of).collect(); let stat = DiffStatWire { insertions: patches @@ -453,7 +453,7 @@ pub(crate) fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire } #[derive(Serialize)] -pub(crate) struct PatchIdentityWire { +pub struct PatchIdentityWire { #[serde(rename = "Name")] pub name: AuthorName, #[serde(rename = "Email")] @@ -461,7 +461,7 @@ pub(crate) struct PatchIdentityWire { } #[derive(Serialize)] -pub(crate) struct FormatPatchWire { +pub struct FormatPatchWire { #[serde(rename = "Files")] pub files: Option>, #[serde(rename = "SHA")] @@ -488,7 +488,7 @@ pub(crate) struct FormatPatchWire { pub raw: String, } -pub(crate) fn normalize_message_section<'a>(lines: impl Iterator) -> String { +pub fn normalize_message_section<'a>(lines: impl Iterator) -> String { lines .map(str::trim_end) .fold((String::new(), 0usize), |(mut out, blanks), line| { @@ -517,12 +517,12 @@ fn entry_mode_decimal(kind: EntryKind) -> u32 { } } -pub(crate) fn entry_mode_octal(kind: EntryKind) -> String { +pub fn entry_mode_octal(kind: EntryKind) -> String { format!("{:06o}", entry_mode_decimal(kind)) } #[derive(Serialize)] -pub(crate) struct FileWire { +pub struct FileWire { #[serde(rename = "OldName")] pub old_name: String, #[serde(rename = "NewName")] diff --git a/knot2/crates/knot-xrpc/Cargo.toml b/knot2/crates/knot-xrpc/Cargo.toml index 0d5ad684e..292367924 100644 --- a/knot2/crates/knot-xrpc/Cargo.toml +++ b/knot2/crates/knot-xrpc/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true [dependencies] knot-types = { workspace = true } knot-git = { workspace = true } +knot-gitcore = { workspace = true } +knot-wire = { workspace = true } knot-lfs = { workspace = true } knot-cache = { workspace = true } knot-pack = { workspace = true } diff --git a/knot2/crates/knot-xrpc/src/error.rs b/knot2/crates/knot-xrpc/src/error.rs index 0652aa496..572d0300b 100644 --- a/knot2/crates/knot-xrpc/src/error.rs +++ b/knot2/crates/knot-xrpc/src/error.rs @@ -111,6 +111,12 @@ impl std::fmt::Display for XrpcError { } } +impl From for XrpcError { + fn from(error: knot_wire::WireError) -> Self { + Self::new(error.status, error.error, error.message) + } +} + impl From for XrpcError { fn from(error: knot_git::GitError) -> Self { use knot_git::GitError; diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 70c785812..acca57559 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -12,15 +12,15 @@ mod lists; mod locks; mod members; mod merge; -mod patchtext; + mod query; mod reads; mod receive; mod repos; mod reservations; mod service; -mod sniff; -mod wire; + +pub(crate) use knot_wire::wire; #[cfg(test)] mod tests; diff --git a/knot2/crates/knot-xrpc/src/query.rs b/knot2/crates/knot-xrpc/src/query.rs index d425f0dbc..06d6b7f5b 100644 --- a/knot2/crates/knot-xrpc/src/query.rs +++ b/knot2/crates/knot-xrpc/src/query.rs @@ -1,7 +1,8 @@ -use axum::extract::{FromRequestParts, Query}; +use axum::extract::FromRequestParts; use http::request::Parts; -use knot_types::{OwnerDid, RepoDid, RepoPath, RepoRkey}; -use serde::de::{self, Deserialize, DeserializeOwned, Deserializer}; +use serde::de::DeserializeOwned; + +pub(crate) use knot_wire::query::*; use crate::error::XrpcError; @@ -15,259 +16,8 @@ where type Rejection = XrpcError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - Query::::try_from_uri(&parts.uri) - .map(|query| ValidatedQuery(query.0)) - .map_err(|rejection| XrpcError::invalid_request(rejection.body_text())) - } -} - -// Each route's default and its limit are included in the type, -// such that a limit that is ok on one endpoint -// can't be spent on another one with a lower roof. -#[derive(Clone, Copy)] -pub(crate) struct Limit(usize); - -impl Limit { - pub(crate) fn get(self) -> usize { - self.0 - } -} - -impl Default for Limit { - fn default() -> Self { - Limit(DEFAULT) - } -} - -impl<'de, const DEFAULT: usize, const MAX: usize> Deserialize<'de> for Limit { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - if raw.is_empty() { - return Ok(Limit(DEFAULT)); - } - let value = raw - .parse::() - .map_err(|_| de::Error::custom("limit must be an integer"))?; - Ok(Limit(usize::try_from(value).unwrap_or(0).min(MAX).max(1))) - } -} - -knot_types::scalar_newtype! { - #[derive(Default)] - pub(crate) struct Offset(usize); -} - -impl<'de> Deserialize<'de> for Offset { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - if raw.is_empty() { - return Ok(Offset::new(0)); - } - raw.parse::() - .map(Offset::new) - .map_err(|_| de::Error::custom("cursor must be an integer")) - } -} - -knot_types::scalar_newtype! { - pub(crate) struct Total(usize); -} - -pub(crate) fn next_cursor( - offset: Offset, - limit: Limit, - total: Total, -) -> Option { - offset - .get() - .checked_add(limit.get()) - .filter(|&end| end < total.get()) - .map(|end| end.to_string()) -} - -#[derive(Clone, Copy, Default, PartialEq, Eq)] -pub(crate) enum Order { - #[default] - Desc, - Asc, -} - -impl Order { - pub(crate) fn descending(self) -> bool { - matches!(self, Order::Desc) - } -} - -impl<'de> Deserialize<'de> for Order { - fn deserialize>(deserializer: D) -> Result { - match String::deserialize(deserializer)?.as_str() { - "" | "desc" => Ok(Order::Desc), - "asc" => Ok(Order::Asc), - _ => Err(de::Error::custom("order must be 'asc' or 'desc'")), - } - } -} - -pub(crate) enum RepoArg { - Did(RepoDid), - OwnerRkey { owner: OwnerDid, rkey: RepoRkey }, -} - -impl RepoArg { - pub(crate) fn basename(&self) -> &str { - match self { - RepoArg::Did(did) => did.as_str(), - RepoArg::OwnerRkey { rkey, .. } => rkey.as_str(), - } - } - - pub(crate) fn to_param(&self) -> String { - match self { - RepoArg::Did(did) => did.as_str().to_string(), - RepoArg::OwnerRkey { owner, rkey } => format!("{}/{}", owner.as_str(), rkey.as_str()), - } - } -} - -impl<'de> Deserialize<'de> for RepoArg { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - if !raw.starts_with("did:") { - return Err(de::Error::custom( - "missing or invalid repo parameter, expected repo DID", - )); - } - Ok(match raw.split_once('/') { - None => RepoArg::Did(RepoDid::new(raw).map_err(de::Error::custom)?), - Some((owner, rkey)) => RepoArg::OwnerRkey { - owner: OwnerDid::new(owner).map_err(de::Error::custom)?, - rkey: RepoRkey::new(rkey).map_err(de::Error::custom)?, - }, - }) - } -} - -const MAX_REVSPEC_BYTES: usize = 4096; - -#[derive(Clone, Default)] -pub(crate) struct Revspec(String); - -impl Revspec { - pub(crate) fn as_str(&self) -> &str { - &self.0 - } -} - -impl<'de> Deserialize<'de> for Revspec { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - match raw.len() <= MAX_REVSPEC_BYTES && !raw.chars().any(char::is_control) { - true => Ok(Self(raw)), - false => Err(de::Error::custom("invalid revision")), - } - } -} - -#[derive(Default)] -pub(crate) struct BranchArg(Option); - -impl BranchArg { - pub(crate) fn get(&self) -> Option<&knot_types::BranchName> { - self.0.as_ref() - } -} - -impl<'de> serde::Deserialize<'de> for BranchArg { - fn deserialize>(deserializer: D) -> Result { - match String::deserialize(deserializer)? { - raw if raw.is_empty() => Ok(Self(None)), - raw => knot_types::BranchName::new(raw) - .map(|name| Self(Some(name))) - .map_err(de::Error::custom), - } - } -} - -#[derive(Default)] -pub(crate) struct TagArg(Option); - -impl TagArg { - pub(crate) fn get(&self) -> Option<&knot_types::TagName> { - self.0.as_ref() - } -} - -impl<'de> serde::Deserialize<'de> for TagArg { - fn deserialize>(deserializer: D) -> Result { - match String::deserialize(deserializer)? { - raw if raw.is_empty() => Ok(Self(None)), - raw => { - let short = raw.strip_prefix("refs/tags/").unwrap_or(&raw); - knot_types::TagName::new(short) - .map(|name| Self(Some(name))) - .map_err(de::Error::custom) - } - } - } -} - -#[derive(Default)] -pub(crate) enum TreePath { - #[default] - Root, - At(RepoPath), - Outside(String), -} - -impl TreePath { - pub(crate) fn as_str(&self) -> &str { - match self { - TreePath::Root => "", - TreePath::At(path) => path.as_str(), - TreePath::Outside(raw) => raw, - } - } - - pub(crate) fn dir(&self) -> Option> { - match self { - TreePath::Root => Some(None), - TreePath::At(path) => Some(Some(path)), - TreePath::Outside(_) => None, - } - } - - pub(crate) fn file(&self) -> Option<&RepoPath> { - match self { - TreePath::At(path) => Some(path), - TreePath::Root | TreePath::Outside(_) => None, - } - } -} - -impl<'de> Deserialize<'de> for TreePath { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - Ok(match raw.is_empty() { - true => TreePath::Root, - false => match RepoPath::new(&raw) { - Ok(path) => TreePath::At(path), - Err(_) => TreePath::Outside(raw), - }, - }) - } -} - -#[derive(Default)] -pub(crate) struct RawFlag(bool); - -impl RawFlag { - pub(crate) fn requested(&self) -> bool { - self.0 - } -} - -impl<'de> Deserialize<'de> for RawFlag { - fn deserialize>(deserializer: D) -> Result { - Ok(RawFlag(String::deserialize(deserializer)? == "true")) + knot_wire::query::parse_query(parts.uri.query().unwrap_or_default()) + .map(ValidatedQuery) + .map_err(XrpcError::from) } } diff --git a/knot2/crates/knot-xrpc/src/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs index 78a24d589..4778ff5e1 100644 --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -1,36 +1,28 @@ -use std::collections::BTreeMap; use std::sync::Arc; use axum::body::Body; -use axum::extract::{Query, Request, State}; +use axum::extract::{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 serde::Serialize; use tower::ServiceExt; use tower_http::services::ServeFile; use knot_cobs::RepoRef; -use knot_git::{ - ArchiveFormat, BinaryBudget, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, - SizedEntry, is_public_ref, screens_reserved, -}; +use knot_git::{BinaryBudget, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo}; use knot_index::{Coverage, Resolved}; use knot_runtime::{Clock, HttpTransport}; -use knot_types::{AuthorName, Email, Oid, OwnerDid, RepoDid, RepoPath, RepoRkey}; +use knot_types::{Oid, RepoDid, RepoPath}; +use knot_wire::reads::{ + self as wire_reads, ArchiveParams, BlobParams, BranchParams, BranchesParams, CompareParams, + DefaultBranchParams, DescribeRepoOut, DescribeRepoParams, DiffParams, LanguagesParams, + ListRefsParams, ListReposOut, ListReposParams, LogParams, MAX_COMPARE_COMMITS, ReadmeOut, + TagParams, TagsParams, TreeParams, +}; 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}; +use crate::query::{RepoArg, Revspec, ValidatedQuery}; +use crate::{XrpcState, run_blocking}; pub(crate) const TREE_ROUTE: &str = "/xrpc/sh.tangled.repo.tree"; pub(crate) const LOG_ROUTE: &str = "/xrpc/sh.tangled.repo.log"; @@ -48,57 +40,16 @@ 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", - ) + wire_reads::repo_not_found().into() } 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}")) + wire_reads::ref_not_found().into() } pub(crate) fn warming() -> XrpcError { - XrpcError::warming("registry projection is still warming") + wire_reads::warming().into() } #[derive(Clone)] @@ -155,7 +106,7 @@ pub(crate) fn open(layout: &Layout, did: &RepoDid) -> Result { fn commit_for(repo: &Repo, refspec: &Revspec) -> Result { let refspec = refspec.as_str(); - if names_reserved(refspec) { + if wire_reads::names_reserved(refspec) { return Err(ref_not_found()); } let oid = match refspec.is_empty() { @@ -179,129 +130,36 @@ fn hidden_staging_commit(repo: &Repo, refspec: &str) -> Option { .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(( + wire_reads::json(value, limit).map(|body| { + ( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], - writer.buf, + body, ) - .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('.'))) + .into_response() + }) + .map_err(XrpcError::from) } fn readme_of( repo: &Repo, commit: Oid, dir: Option<&RepoPath>, - entries: &[SizedEntry], + entries: &[knot_git::SizedEntry], response_limit: usize, ) -> ReadmeOut { entries .iter() - .filter(|entry| is_readme(entry)) + .filter(|entry| wire_reads::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) { + if repo.blob_size(target.oid).ok()? > wire_reads::readme_serving_limit(response_limit) + { return None; } let contents = repo.read_blob(target.oid).ok()?; @@ -310,10 +168,7 @@ fn readme_of( contents, }) }) - .unwrap_or(ReadmeOut { - filename: String::new(), - contents: String::new(), - }) + .unwrap_or_else(ReadmeOut::empty) } pub(crate) async fn repo_tree( @@ -327,431 +182,161 @@ pub(crate) async fn repo_tree( 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 dir = params.path.dir().ok_or_else(wire_reads::path_not_found)?; let entries = repo .tree_entries_at(commit, dir)? - .ok_or_else(path_not_found)?; + .ok_or_else(wire_reads::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 newest_commit = wire_reads::newest(&attributed) + .and_then(|last| repo.find_commit(last.id).ok()); 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, + wire_reads::tree_out( + ¶ms.refspec, + ¶ms.path, + &entries, + &attributed, + newest_commit.as_ref(), 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))?; + let (commits, total) = repo.log_window( + start, + LogSkip::new(params.cursor.get()), + LogLimit::new(params.limit.get()), + )?; 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, - }, + wire_reads::log_out(¶ms, &commits, total), 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) + let branches = repo.branch_list()?; + let default = repo.default_branch(); + json( + wire_reads::branches_out( + ¶ms, + branches, + default.as_ref().map(|name| name.as_str()), + repo.object_format().null_oid(), + ), + 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")); + return Err(wire_reads::missing_name().into()); }; 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(); + .ok_or_else(wire_reads::branch_not_found)?; + let commit = repo + .find_commit(target) + .map_err(|_| wire_reads::branch_not_found())?; + let default = repo.default_branch(); 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()), - }, + wire_reads::branch_out( + &name, + target, + &commit, + default.as_ref().map(|name| 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) + let tags = repo.tag_list()?; + json(wire_reads::tags_out(¶ms, tags), 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")); + return Err(wire_reads::missing_tag().into()); }; 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, - ) + let tags = repo.tag_list()?; + json(wire_reads::tag_out(&name, tags)?, 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 raw_response(raw: wire_reads::RawResponse) -> Response { + let mut response = (raw.status, raw.body).into_response(); + raw.headers.into_iter().for_each(|(name, value)| { + if let Ok(value) = HeaderValue::from_str(&value) { + response.headers_mut().insert(name, value); + } + }); + response } -fn etag_matches(headers: &HeaderMap, etag: &str) -> bool { +fn if_none_match(headers: &HeaderMap) -> impl Iterator { 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( @@ -761,53 +346,21 @@ pub(crate) async fn repo_blob( ) -> Result { let did = resolve_repo(&state, ¶ms.repo)?; if params.path.as_str().is_empty() { - return Err(XrpcError::invalid_request("missing path parameter")); + return Err(wire_reads::missing_path().into()); } 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 submodules = repo.submodules(commit).unwrap_or_default(); + if let Some(submodule) = wire_reads::submodule_at(&submodules, &path) { + return json(wire_reads::submodule_out(¶ms, submodule), 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 file_path = params.path.file().ok_or_else(wire_reads::file_not_found)?; let entry = repo .entry_at(commit, file_path)? .filter(|entry| { @@ -816,27 +369,25 @@ pub(crate) async fn repo_blob( 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) + .ok_or_else(wire_reads::file_not_found)?; + if repo + .blob_size(entry.oid) + .map_err(|_| wire_reads::file_not_found())? + > wire_reads::blob_serving_limit(raw, limit) { - return Err(blob_too_large()); + return Err(wire_reads::blob_too_large().into()); } - let contents = repo.read_blob(entry.oid).map_err(|_| file_not_found())?; - let mime = sniff::override_by_extension(&path, sniff::detect_content_type(&contents)); + let contents = repo + .read_blob(entry.oid) + .map_err(|_| wire_reads::file_not_found())?; + let mime = wire_reads::blob_mime(&path, &contents); if raw { - return serve_raw(&headers, mime, contents); + return wire_reads::serve_raw(if_none_match(&headers), mime, contents) + .map(raw_response) + .map_err(XrpcError::from); } - 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 @@ -848,48 +399,18 @@ pub(crate) async fn repo_blob( ) .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(), - }), + .map(|last| { + let author = repo.find_commit(last.id).ok(); + wire_reads::LastCommitOut::of(&last, author.as_ref()) }); 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, - }, + wire_reads::blob_out(¶ms, mime, &contents, last_commit), 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, @@ -908,125 +429,26 @@ pub(crate) async fn repo_diff( }, &mut BinaryBudget::Omit, )?; - json( - DiffOut { - refspec: params.refspec.as_str().to_string(), - diff: nice_diff(&commit, &patches), - }, - limit, - ) + json(wire_reads::diff_out(¶ms, &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 = "Option::is_none")] - merge_base: Option, - #[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, - #[serde(skip_serializing_if = "Option::is_none")] - binary_omitted: 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 (rev1, rev2) = params.revs()?; let layout = state.layout.clone(); let limit = state.byte_limits.response.get(); let mut series_binary = state.byte_limits.binary_patch(); let mut combined_binary = state.byte_limits.binary_patch(); 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) { + let resolve = |rev: &str| -> Result { + let revision_not_found = || XrpcError::from(wire_reads::revision_not_found(rev)); + if wire_reads::names_reserved(rev) { return Err(revision_not_found()); } let commit = repo @@ -1044,13 +466,7 @@ pub(crate) async fn repo_compare( }; 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 compare_error = |error: knot_git::GitError| wire_reads::compare_error(error); let between = repo .commits_between( CommitRange { base, head }, @@ -1058,22 +474,16 @@ pub(crate) async fn repo_compare( ) .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"), - )); + return Err(wire_reads::compare_too_long().into()); } - 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 + let commits = wire_reads::compare_series( + between + .into_iter() + .map(|oid| repo.find_commit(oid)) + .collect::, _>>() + .map_err(compare_error)?, + ); + let entries = commits .iter() .map(|commit| { repo.commit_patches( @@ -1083,19 +493,12 @@ pub(crate) async fn repo_compare( }, &mut series_binary, ) - .map(|patches| { - let raw = render_format_patch(commit, &patches); - (format_patch_entry(commit, &patches, &raw), raw) - }) + .map(|patches| wire_reads::series_entry(commit, &patches)) }) .collect::, _>>() .map_err(compare_error)?; - let patch_raw: String = entries - .iter() - .flat_map(|(_, raw)| [raw.as_str(), "\n"]) - .collect(); let merge_base = repo.merge_base(base, head).ok().flatten(); - let (combined_patch, combined_patch_raw) = match (commits.len() >= 2, merge_base) { + let combined = match (commits.len() >= 2, merge_base) { (true, Some(merge_base)) => repo .commit_patches( knot_git::PatchRange { @@ -1104,182 +507,24 @@ pub(crate) async fn repo_compare( }, &mut combined_binary, ) - .ok() - .map(|patches| { - ( - Some(patches.iter().map(FileWire::of).collect::>()), - Some(render_patches(&patches)), - ) - }) - .unwrap_or((None, None)), - _ => (None, None), + .ok(), + _ => None, }; json( - CompareOut { - rev1: base.to_hex(), - rev2: head.to_hex(), - merge_base: merge_base.map(|oid| oid.to_hex()), - format_patch: entries.into_iter().map(|(entry, _)| entry).collect(), - patch_raw, - combined_patch, - combined_patch_raw, - binary_omitted: (series_binary.omitted() || combined_binary.omitted()) - .then_some(true), - }, + wire_reads::compare_out( + base, + head, + merge_base, + entries, + combined, + series_binary.omitted() || combined_binary.omitted(), + ), 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)?; - let cleaned = raw - .split('/') - .filter(|component| !component.is_empty() && *component != ".") - .collect::>() - .join("/"); - match cleaned.is_empty() { - true => Ok(Self(None)), - false => knot_git::ArchivePrefix::new(cleaned) - .ok() - .filter(|prefix| { - !prefix.as_str().contains('\\') && !prefix.as_str().contains(char::is_control) - }) - .map(|prefix| Self(Some(prefix))) - .ok_or_else(|| { - de::Error::custom(format!( - "archive prefix must stay inside the archive root within {} bytes, and mustn't contain a backslash or a control character", - knot_git::ArchivePrefix::MAX_BYTES - )) - }), - } - } -} - -#[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) -> &str { - ["refs/heads/", "refs/tags/", "refs/remotes/", "refs/"] - .into_iter() - .find_map(|prefix| refspec.strip_prefix(prefix)) - .unwrap_or(refspec) -} - -fn sanitize_filename(name: &str) -> String { - name.replace( - |c: char| c.is_ascii_control() || matches!(c, '"' | '\\' | '/'), - "-", - ) -} - -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) } @@ -1310,23 +555,19 @@ 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 params: ArchiveParams = + knot_wire::query::parse_query(request.uri().query().unwrap_or_default())?; let did = resolve_repo(&state, ¶ms.repo)?; let format = params.format; let format_name = format.name(); - let archive_prefix = match ¶ms.prefix.0 { - Some(prefix) => prefix.clone(), - None => { - let registered = state.index.rkey_of(&did); - let name = match ®istered { - Resolved::Ready(Some(rkey)) => rkey.as_str(), - _ => params.repo.basename(), - }; - knot_git::ArchivePrefix::stem(name, short_ref(params.refspec.as_str())) - } - }; + let registered = state.index.rkey_of(&did); + let archive_prefix = wire_reads::archive_prefix( + ¶ms, + match ®istered { + Resolved::Ready(Some(rkey)) => Some(rkey), + _ => None, + }, + ); let (resolved, modified_secs) = run_blocking({ let layout = state.layout.clone(); @@ -1344,22 +585,16 @@ pub(crate) async fn repo_archive( }) .await?; - let link = { - let mut query = url::form_urlencoded::Serializer::new(String::new()); - query.append_pair("format", format_name); - query.append_pair("prefix", archive_prefix.as_str()); - query.append_pair("ref", &resolved.to_hex()); - query.append_pair("repo", ¶ms.repo.to_param()); - format!( - "<{}/xrpc/sh.tangled.repo.archive?{}>; rel=\"immutable\"", - state.knot_service_url.as_str(), - query.finish() - ) - }; - - let etag = archive_etag(&did, resolved, format.format(), archive_prefix.as_str()); - let disposition = content_disposition(&format!("{}.{format_name}", archive_prefix.as_str())); - if etag_matches(request.headers(), &etag) { + let link = wire_reads::archive_link( + state.knot_service_url.as_str(), + ¶ms, + &archive_prefix, + resolved, + ); + let etag = wire_reads::archive_etag(&did, resolved, format.format(), archive_prefix.as_str()); + let disposition = + wire_reads::content_disposition(&format!("{}.{format_name}", archive_prefix.as_str())); + if wire_reads::etag_matches(if_none_match(request.headers()), &etag) { return Ok(( StatusCode::NOT_MODIFIED, [ @@ -1394,11 +629,7 @@ pub(crate) async fn repo_archive( .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}"), - ), + false => wire_reads::archive_error(error).into(), } })?; temp.as_file() @@ -1431,31 +662,6 @@ pub(crate) async fn repo_archive( 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, @@ -1468,43 +674,11 @@ pub(crate) async fn repo_languages( 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, - ) + json(wire_reads::languages_out(¶ms, &sizes), 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, @@ -1514,43 +688,15 @@ pub(crate) async fn repo_get_default_branch( 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", - ) - })?; + let default = repo.default_branch(); json( - DefaultBranchOut { - name, - hash: String::new(), - when: rfc3339(0, 0), - }, + wire_reads::default_branch_out(default.as_ref().map(|name| name.as_str()))?, 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, @@ -1571,108 +717,34 @@ pub(crate) async fn repo_describe_repo( ) } -#[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 + let refs: Vec<_> = repo .references()? .into_iter() - .filter(|record| is_public_ref(&record.name)) + .map(|record| (record.name, record.target)) .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()), - }); + let head = repo.head(); json( - ListRefsOut { - refs: window, - cursor, - default_branch, - }, + wire_reads::list_refs_out( + ¶ms, + &refs, + head.as_ref() + .map(|head| (head.name.as_str(), head.target)), + ), 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, @@ -1680,68 +752,21 @@ pub(crate) async fn sync_list_repos( 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 (page, cursor) = wire_reads::list_repos_page(¶ms, state.index.hosted_repos()); 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(); + let repos = page + .into_iter() + .map(|did| { + let head = open(&layout, &did).ok().and_then(|repo| repo.head()); + wire_reads::repo_wire( + did, + head.as_ref().map(|head| (head.name.as_str(), head.target)), + ) + }) + .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-._~"); - } -} -- 2.51.2