Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
30 kB · 933 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934//! bounded static policies: compare, log pages, archives, blame profiles.
use std::collections::{BTreeMap, BTreeSet};use std::io::Cursor;use std::time::{Duration, Instant};
use knot_git::{ ArchiveFormat, ArchiveLimit, ArchivePrefix, BinaryBudget, Commit, CommitChangeId, CommitRange, EntryKind, FilePatch, Hunk, LineOp, LogLimit, LogSkip, PatchRange, PatchStatus, Repo,};use knot_types::{AuthorName, Email, Oid, RepoPath};use crate::project::out::{BlameAuthorWire, BlameCommitWire, BlameRunWire, BlameWire};use serde::ser::SerializeSeq;use serde::{Serialize, Serializer};
use crate::config::BlameProfile;use crate::project::errors::StaticXrpcError;use crate::project::pagination::{self, Endpoint};use crate::project::patchtext::{render_format_patch, render_patches};use crate::project::wire::{entry_mode_decimal, fold_subject, message_body, rfc2822, rfc3339};
pub const DEFAULT_BINARY_PATCH_BUDGET: u64 = 2 * 1024 * 1024; // 2 MiB
pub const MAX_COMPARE_COMMITS: usize = 500;pub const MAX_BLAME_OBJECT_BYTES: u64 = 1024 * 1024; // 1 MiBpub const MAX_BLAME_RUNS: usize = 50_000;pub const BLAME_BUDGET: Duration = Duration::from_secs(5); // mirrors knot-xrpc BLAME_TIMEOUTpub const ZERO_TIME: &str = "0001-01-01T00:00:00Z";
// ============================================================================// wire types for compare and log// ============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct HashBytes(pub Oid);
impl Serialize for HashBytes { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { let bytes = self.0.object_id(); let mut seq = serializer.serialize_seq(Some(bytes.as_bytes().len()))?; bytes .as_bytes() .iter() .try_for_each(|byte| seq.serialize_element(byte))?; seq.end() }}
fn display_opt<S: Serializer>( value: &Option<CommitChangeId>, serializer: S,) -> Result<S::Ok, S::Error> { match value { Some(id) => serializer.serialize_str(id.as_str()), None => serializer.serialize_none(), }}
#[derive(Debug, Clone, Serialize)]pub struct SignatureWire { #[serde(rename = "Name")] pub name: AuthorName, #[serde(rename = "Email")] pub email: Email, #[serde(rename = "When")] pub when: String,}
impl SignatureWire { pub fn of(identity: &knot_git::Identity) -> Self { Self { name: identity.name.clone(), email: identity.email.clone(), when: rfc3339(identity.time.get(), identity.offset_seconds), } }}
#[derive(Debug, Clone, Serialize)]pub struct CommitWire { pub hash: HashBytes, pub author: SignatureWire, pub committer: SignatureWire, pub message: String, pub tree: Oid, #[serde(skip_serializing_if = "Vec::is_empty")] pub parent_hashes: Vec<HashBytes>, #[serde(skip_serializing_if = "Option::is_none")] pub pgp_signature: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub merge_tag: Option<String>, #[serde( skip_serializing_if = "Option::is_none", serialize_with = "display_opt" )] pub change_id: Option<CommitChangeId>, pub this: Oid, #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option<Oid>,}
impl CommitWire { pub fn of(commit: &Commit) -> Self { Self { hash: HashBytes(commit.id), author: SignatureWire::of(&commit.author), committer: SignatureWire::of(&commit.committer), message: commit.message.clone(), tree: commit.tree, parent_hashes: commit.parents.iter().copied().map(HashBytes).collect(), pgp_signature: commit.pgp_signature.clone(), merge_tag: commit.merge_tag.clone(), change_id: commit.change_id(), this: commit.id, parent: commit.parents.first().copied(), } }}
#[derive(Debug, Clone, Serialize)]pub struct LineWire { #[serde(rename = "Op")] pub op: u8, #[serde(rename = "Line")] pub line: String,}
#[derive(Debug, Clone, Serialize)]pub struct TextFragmentWire { #[serde(rename = "Comment")] pub comment: String, #[serde(rename = "OldPosition")] pub old_position: i64, #[serde(rename = "OldLines")] pub old_lines: i64, #[serde(rename = "NewPosition")] pub new_position: i64, #[serde(rename = "NewLines")] pub new_lines: i64, #[serde(rename = "LinesAdded")] pub lines_added: i64, #[serde(rename = "LinesDeleted")] pub lines_deleted: i64, #[serde(rename = "LeadingContext")] pub leading_context: i64, #[serde(rename = "TrailingContext")] pub trailing_context: i64, #[serde(rename = "Lines")] pub lines: Vec<LineWire>,}
impl TextFragmentWire { pub fn of(hunk: &Hunk) -> Self { let lines: Vec<LineWire> = hunk .lines .iter() .map(|line| LineWire { op: match line.op { LineOp::Context => 0, LineOp::Delete => 1, LineOp::Add => 2, }, line: String::from_utf8_lossy(&line.text).into_owned(), }) .collect(); let leading = lines.iter().take_while(|line| line.op == 0).count(); let trailing = if leading == lines.len() { 0 } else { lines.iter().rev().take_while(|line| line.op == 0).count() }; Self { comment: String::new(), old_position: hunk.old_start.get() as i64, old_lines: hunk.old_lines.get() as i64, new_position: hunk.new_start.get() as i64, new_lines: hunk.new_lines.get() as i64, lines_added: hunk.added().get() as i64, lines_deleted: hunk.deleted().get() as i64, leading_context: leading as i64, trailing_context: trailing as i64, lines, } }}
#[derive(Debug, Clone, Serialize)]pub struct FileWire { #[serde(rename = "OldName")] pub old_name: String, #[serde(rename = "NewName")] pub new_name: String, #[serde(rename = "IsNew")] pub is_new: bool, #[serde(rename = "IsDelete")] pub is_delete: bool, #[serde(rename = "IsCopy")] pub is_copy: bool, #[serde(rename = "IsRename")] pub is_rename: bool, #[serde(rename = "OldMode")] pub old_mode: u32, #[serde(rename = "NewMode")] pub new_mode: u32, #[serde(rename = "OldOIDPrefix")] pub old_oid_prefix: String, #[serde(rename = "NewOIDPrefix")] pub new_oid_prefix: String, #[serde(rename = "Score")] pub score: i64, #[serde(rename = "TextFragments")] pub text_fragments: Option<Vec<TextFragmentWire>>, #[serde(rename = "IsBinary")] pub is_binary: bool, #[serde(rename = "BinaryFragment")] pub binary_fragment: Option<()>, #[serde(rename = "ReverseBinaryFragment")] pub reverse_binary_fragment: Option<()>,}
impl FileWire { pub fn of(patch: &FilePatch) -> Self { let fragments: Vec<TextFragmentWire> = patch.hunks().iter().map(TextFragmentWire::of).collect(); let same_mode = patch.old_kind.is_some() && patch.old_kind == patch.new_kind; Self { old_name: match patch.status { PatchStatus::Added => String::new(), _ => patch.path.to_string(), }, new_name: match patch.status { PatchStatus::Deleted => String::new(), _ => patch.path.to_string(), }, is_new: patch.status == PatchStatus::Added, is_delete: patch.status == PatchStatus::Deleted, is_copy: false, is_rename: false, old_mode: match patch.status { PatchStatus::Added => 0, PatchStatus::Deleted | PatchStatus::Modified => { patch.old_kind.map_or(0, entry_mode_decimal) } }, new_mode: match patch.status { PatchStatus::Added => patch.new_kind.map_or(0, entry_mode_decimal), PatchStatus::Deleted => 0, PatchStatus::Modified if same_mode => 0, PatchStatus::Modified => patch.new_kind.map_or(0, entry_mode_decimal), }, old_oid_prefix: patch.old_oid.to_hex(), new_oid_prefix: patch.new_oid.to_hex(), score: 0, text_fragments: (!fragments.is_empty()).then_some(fragments), is_binary: patch.is_binary(), binary_fragment: None, reverse_binary_fragment: None, } }}
#[derive(Debug, Clone, Serialize)]pub struct PatchIdentityWire { #[serde(rename = "Name")] pub name: AuthorName, #[serde(rename = "Email")] pub email: Email,}
#[derive(Debug, Clone, Serialize)]pub struct FormatPatchWire { #[serde(rename = "Files")] pub files: Option<Vec<FileWire>>, #[serde(rename = "SHA")] pub sha: Oid, #[serde(rename = "Author")] pub author: Option<PatchIdentityWire>, #[serde(rename = "AuthorDate")] pub author_date: String, #[serde(rename = "Committer")] pub committer: Option<()>, #[serde(rename = "CommitterDate")] pub committer_date: String, #[serde(rename = "Title")] pub title: String, #[serde(rename = "Body")] pub body: String, #[serde(rename = "SubjectPrefix")] pub subject_prefix: String, #[serde(rename = "BodyAppendix")] pub body_appendix: String, #[serde(rename = "RawHeaders")] pub raw_headers: Option<BTreeMap<String, Vec<String>>>, #[serde(rename = "Raw")] pub raw: String,}
fn normalize_message_section<'a>(lines: impl Iterator<Item = &'a str>) -> String { lines .map(str::trim_end) .fold((String::new(), 0usize), |(mut out, blanks), line| { if line.is_empty() { (out, blanks + 1) } else { if !out.is_empty() { out.push('\n'); if blanks > 0 { out.push('\n'); } } out.push_str(line); (out, 0) } }) .0}
fn appendix_lines(raw: &str) -> impl Iterator<Item = &str> { raw.split_once("\n---\n") .map_or("", |(_, rest)| rest) .split("\ndiff --git ") .next() .unwrap_or_default() .lines()}
fn format_patch_entry(commit: &Commit, patches: &[FilePatch], raw: &str) -> FormatPatchWire { let title = fold_subject(&commit.message); let mut raw_headers: BTreeMap<String, Vec<String>> = 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<FileWire> = 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(), }}
// ============================================================================// compare policy (packet §9, §21)// ============================================================================
#[derive(Debug, Clone, Serialize)]pub struct CompareOut { pub rev1: String, pub rev2: String, #[serde(skip_serializing_if = "Option::is_none")] pub merge_base: Option<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub format_patch: Vec<FormatPatchWire>, #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] pub patch_raw: String, #[serde(skip_serializing_if = "Option::is_none")] pub combined_patch: Option<Vec<FileWire>>, #[serde(skip_serializing_if = "Option::is_none")] pub combined_patch_raw: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub binary_omitted: Option<bool>,}
/// validate whether `(rev1, rev2)` is within the bounded compare profile:/// only default tip <-> live branch tips, in both directions.pub fn is_allowed_compare_pair( default_branch: &str, live_branches: &[&str], rev1: &str, rev2: &str,) -> bool { let d = default_branch.trim_start_matches("refs/heads/"); let r1 = rev1.trim_start_matches("refs/heads/"); let r2 = rev2.trim_start_matches("refs/heads/");
let is_live = |name: &str| { live_branches .iter() .any(|&b| b.trim_start_matches("refs/heads/") == name) };
(r1 == d && is_live(r2)) || (r2 == d && is_live(r1))}
/// compute all allowed compare pairs for live branches against the default branch.pub fn bounded_compare_pairs<'a>( default_branch: &'a str, live_branches: &'a [String],) -> Vec<(&'a str, &'a str)> { let d = default_branch.trim_start_matches("refs/heads/"); live_branches .iter() .map(String::as_str) .filter(|&b| b.trim_start_matches("refs/heads/") != d) .flat_map(|b| [(default_branch, b), (b, default_branch)]) .collect()}
/// execute a comparison between two commit OIDs, reusing knot2's exact comparison/// logic (format-patch series, merge-base, 500-commit bound).pub fn execute_compare(repo: &Repo, base: Oid, head: Oid) -> Result<CompareOut, StaticXrpcError> { let compare_error = |error: knot_git::GitError| { StaticXrpcError::new( 400, "CompareError", format!("error comparing revisions: {error}"), ) };
let between = repo .commits_between( CommitRange { base, head }, LogLimit::new(MAX_COMPARE_COMMITS + 1), ) .map_err(compare_error)?;
if between.len() > MAX_COMPARE_COMMITS { return Err(StaticXrpcError::new( 400, "CompareError", format!("comparison spans more than maximum of {MAX_COMPARE_COMMITS} commits"), )); }
let commits: Vec<Commit> = between .into_iter() .map(|oid| repo.find_commit(oid)) .collect::<Result<Vec<_>, _>>() .map_err(compare_error)? .into_iter() .rev() .filter(|commit| commit.parents.len() <= 1) .collect();
let mut series_binary = BinaryBudget::new(DEFAULT_BINARY_PATCH_BUDGET); let mut combined_binary = BinaryBudget::new(DEFAULT_BINARY_PATCH_BUDGET);
let entries: Vec<(FormatPatchWire, String)> = commits .iter() .map(|commit| { repo.commit_patches( PatchRange { base: commit.parents.first().copied(), head: commit.id, }, &mut series_binary, ) .map(|patches| { let raw = render_format_patch(commit, &patches); (format_patch_entry(commit, &patches, &raw), raw) }) }) .collect::<Result<Vec<_>, _>>() .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) = (commits.len() >= 2) .then_some(merge_base) .flatten() .and_then(|mb| { repo.commit_patches( PatchRange { base: Some(mb), head, }, &mut combined_binary, ) .ok() }) .map(|patches| { ( Some(patches.iter().map(FileWire::of).collect()), Some(render_patches(&patches)), ) }) .unwrap_or_default();
Ok(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), })}
/// evaluate a compare request against the bounded static profile; checks pair eligibility/// first, returning `CompareError` or `InvalidRequest` if out of profile.pub fn evaluate_compare( repo: &Repo, default_branch: &str, live_branches: &[&str], rev1: &str, rev2: &str, base: Oid, head: Oid,) -> Result<CompareOut, StaticXrpcError> { if !is_allowed_compare_pair(default_branch, live_branches, rev1, rev2) { return Err(StaticXrpcError::new( 400, "InvalidRequest", format!( "comparison pair ({rev1} <-> {rev2}) is not in profile (only default branch <-> live branch tips allowed)" ), )); }
execute_compare(repo, base, head)}
// ============================================================================// log policy (packet §21)// ============================================================================
fn is_zero(value: &usize) -> bool { *value == 0}
#[derive(Debug, Clone, Serialize)]pub struct LogOut { #[serde(skip_serializing_if = "Vec::is_empty")] pub commits: Vec<CommitWire>, #[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,}
/// execute a single log page window against the pagination profile.pub fn execute_log( repo: &Repo, start_commit: Oid, refspec: &str, description: &str, raw_limit: Option<&str>, cursor: Option<&str>,) -> Result<LogOut, StaticXrpcError> { let (effective_limit, offset) = pagination::page_in_profile(Endpoint::Log, raw_limit, cursor)?;
let (commits, total) = repo .log_window( start_commit, LogSkip::new(offset), LogLimit::new(effective_limit), ) .map_err(|e| StaticXrpcError::new(500, "InternalError", format!("log failed: {e}")))?;
// live knot-xrpc sets `description` from the request's `path` param; the static // profile only generates pathless log pages, so it stays empty and serde skips it, // matching the live wire shape byte-for-byte. let _ = description; Ok(LogOut { commits: commits.iter().map(CommitWire::of).collect(), refspec: refspec.to_string(), description: String::new(), log: true, total, page: (offset / effective_limit) + 1, per_page: effective_limit, })}
/// generate all bounded log pages for a named reference, following the pagination table/// (10@0, 20@0, default 50, and 60).pub fn generate_log_pages_for_ref( repo: &Repo, start_commit: Oid, refspec: &str, description: &str,) -> Result<Vec<(usize, usize, LogOut)>, StaticXrpcError> { let mut pages = vec![ ( 10, 0, execute_log( repo, start_commit, refspec, description, Some("10"), Some("0"), )?, ), ( 20, 0, execute_log( repo, start_commit, refspec, description, Some("20"), Some("0"), )?, ), ];
for (limit, raw_limit) in [(50, ""), (60, "60")] { let mut offset = 0; loop { let cursor = offset.to_string(); let out = execute_log( repo, start_commit, refspec, description, Some(raw_limit), Some(&cursor), )?; let total = out.total; pages.push((limit, offset, out)); offset += limit; if offset >= total { break; } } }
Ok(pages)}
// ============================================================================// archive policy (packet §13)// ============================================================================
#[derive(Debug, Clone)]pub struct ArchiveOutput { pub bytes: Vec<u8>, pub content_type: &'static str, pub content_disposition: String, pub format: ArchiveFormat, pub prefix: String,}
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)}
pub 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() .fold(String::with_capacity(name.len()), |mut out, 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'~' => out.push(byte as char), _ => { use std::fmt::Write as _; let _ = write!(out, "%{byte:02X}"); } } out })}
pub fn content_disposition(filename: &str) -> String { let safe = sanitize_filename(filename); let ascii: String = safe .chars() .map(|c| if c.is_ascii() { c } else { '-' }) .collect(); if safe == ascii { format!("attachment; filename=\"{ascii}\"") } else { format!( "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}", rfc5987_encode(&safe) ) }}
/// compute default archive prefix stem (rkey + short ref).pub fn default_archive_stem(rkey: &str, refspec: &str) -> ArchivePrefix { ArchivePrefix::stem(rkey, short_ref(refspec))}
/// write archive for commit OID in tar.gz or zip with the default prefix;/// returns the raw bytes and content-type/disposition metadata.pub fn execute_archive( repo: &Repo, commit_oid: Oid, rkey: &str, refspec: &str, format: ArchiveFormat, custom_prefix: Option<&str>,) -> Result<ArchiveOutput, StaticXrpcError> { let stem = default_archive_stem(rkey, refspec);
// custom prefixes are not supported in static v1 if custom_prefix.is_some_and(|custom| !custom.is_empty() && custom != stem.as_str()) { return Err(StaticXrpcError::new( 400, "InvalidRequest", "custom archive prefixes are not supported in static profile", )); }
let (format_ext, content_type) = match format { ArchiveFormat::TarGz => ("tar.gz", "application/gzip"), ArchiveFormat::Zip => ("zip", "application/zip"), ArchiveFormat::Tar => ("tar", "application/x-tar"), };
let tree = repo.peel_to_tree(commit_oid).map_err(|e| { StaticXrpcError::new( 404, "RefNotFound", format!("cannot peel commit to tree: {e}"), ) })?;
let tree_prefix = stem.clone().into_tree_prefix(); let mut cursor = Cursor::new(Vec::new());
repo.write_archive( tree, format, Some(&tree_prefix), ArchiveLimit::default(), &mut cursor, ) .map_err(|e| match e { knot_git::GitError::ArchiveTooLarge { .. } => { StaticXrpcError::new(413, "RequestTooLarge", "archive exceeds size limit") } other => StaticXrpcError::new( 500, "InternalError", format!("cannot write archive: {other}"), ), })?;
let filename = format!("{}.{format_ext}", stem.as_str()); let disp = content_disposition(&filename);
Ok(ArchiveOutput { bytes: cursor.into_inner(), content_type, content_disposition: disp, format, prefix: stem.as_str().to_string(), })}
// ============================================================================// blame policy (packet §12)// ============================================================================
/// check whether a file is eligible for blame under the active profile and knot2 bounds:/// - off: returns 404 FileNotFound/// - tips: requires `is_tip == true`, otherwise 404 RevisionNotFound/// - bounds: blob size <= 1 MiB (returns 413 BlameTooLarge if larger)pub fn check_blame_eligibility( profile: BlameProfile, is_tip: bool, object_size: u64,) -> Result<(), StaticXrpcError> { match profile { BlameProfile::Off => Err(StaticXrpcError::new( 404, "FileNotFound", "blame is disabled in static profile", )), BlameProfile::Tips if !is_tip => Err(StaticXrpcError::new( 404, "RevisionNotFound", "blame for historical commits is not pre-generated in tips-only profile", )), BlameProfile::Tips | BlameProfile::All if object_size > MAX_BLAME_OBJECT_BYTES => { Err(StaticXrpcError::new( 413, "BlameTooLarge", format!( "file size ({object_size} bytes) exceeds maximum blame limit of {MAX_BLAME_OBJECT_BYTES} bytes" ), )) } BlameProfile::Tips | BlameProfile::All => Ok(()), }}
/// verify that blame run count respects the knot2 bound of <= 50,000 runs.pub fn check_blame_runs(run_count: usize) -> Result<(), StaticXrpcError> { if run_count > MAX_BLAME_RUNS { return Err(StaticXrpcError::new( 413, "BlameTooLarge", format!("blame run count ({run_count}) exceeds maximum of {MAX_BLAME_RUNS}"), )); } Ok(())}
/// execute blame for a file at commit under the specified profile.////// validates profile mode, tree path existence, blob size <= 1 MiB, and run count bound.pub fn execute_blame( repo: &Repo, profile: BlameProfile, commit: Oid, is_tip: bool, path: &RepoPath,) -> Result<Vec<u8>, StaticXrpcError> { let entry = repo .entry_at(commit, path) .map_err(|e| StaticXrpcError::new(500, "InternalError", format!("lookup failed: {e}")))? .filter(|e| { matches!( e.kind, EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link ) }) .ok_or_else(|| StaticXrpcError::new(404, "PathNotFound", "path not found at commit"))?;
let size = repo .blob_size(entry.oid) .map_err(|e| StaticXrpcError::new(500, "InternalError", format!("blob size error: {e}")))?; check_blame_eligibility(profile, is_tip, size)?;
let deadline = Instant::now() + BLAME_BUDGET; let hunks = repo .blame_file_bounded(commit, path, MAX_BLAME_OBJECT_BYTES, deadline) .map_err(|e| match e { knot_git::GitError::BlameObjectTooLarge { .. } => StaticXrpcError::new( 413, "BlameTooLarge", format!("file size exceeds maximum blame limit of {MAX_BLAME_OBJECT_BYTES} bytes"), ), knot_git::GitError::BlameTimedOut => StaticXrpcError::new( 503, "BlameTimeout", "blame exceeded its computation time budget", ), other => StaticXrpcError::new(500, "InternalError", format!("blame failed: {other}")), })?; check_blame_runs(hunks.len())?;
let commits = hunks .iter() .map(|hunk| hunk.commit) .collect::<BTreeSet<_>>() .into_iter() .map(|id| { let commit = repo.find_commit(id).map_err(|e| { StaticXrpcError::new(500, "InternalError", format!("blame commit lookup failed: {e}")) })?; Ok(BlameCommitWire { sha: id, subject: fold_subject(&commit.message), authored_at: rfc3339(commit.author.time.get(), commit.author.offset_seconds), author: BlameAuthorWire { name: commit.author.name.clone(), email: commit.author.email.clone(), }, }) }) .collect::<Result<Vec<_>, StaticXrpcError>>()?; let runs = hunks .iter() .map(|hunk| BlameRunWire { start: hunk.start, count: hunk.lines, commit: hunk.commit, }) .collect();
serde_json::to_vec(&BlameWire { commit, commits, runs, }) .map_err(|e| StaticXrpcError::new(500, "InternalError", format!("blame encode failed: {e}")))}