From ad9724b3271a170ee5b0fd81e5e3098303599623 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 12 Aug 2026 11:36:50 +0300 Subject: [PATCH] knot2,appview: put binary payloads in patches Lewis: May this revision serve well! --- .../repo/pulls/fragments/pullStepReview.html | 6 + appview/pulls/create.go | 8 + knot2/crates/knot-git/src/base85.rs | 154 +++++++++++ knot2/crates/knot-git/src/lib.rs | 7 +- knot2/crates/knot-git/src/patch.rs | 257 ++++++++++++++++-- knot2/crates/knot-git/src/patch_parse.rs | 204 +++++++++----- knot2/crates/knot-git/tests/reads.rs | 115 ++++---- knot2/crates/knot-xrpc/src/lib.rs | 11 + knot2/crates/knot-xrpc/src/patchtext.rs | 159 ++++++----- knot2/crates/knot-xrpc/src/reads.rs | 50 ++-- knot2/crates/knot-xrpc/src/tests.rs | 20 ++ knot2/crates/knot-xrpc/src/wire.rs | 12 +- knot2/crates/knot-xrpc/tests/common/mod.rs | 104 ++++--- knot2/crates/knot-xrpc/tests/reads.rs | 128 ++++++--- types/repo.go | 1 + 15 files changed, 918 insertions(+), 318 deletions(-) create mode 100644 knot2/crates/knot-git/src/base85.rs diff --git a/appview/pages/templates/repo/pulls/fragments/pullStepReview.html b/appview/pages/templates/repo/pulls/fragments/pullStepReview.html index d28cf761..c6b55ace 100644 --- a/appview/pages/templates/repo/pulls/fragments/pullStepReview.html +++ b/appview/pages/templates/repo/pulls/fragments/pullStepReview.html @@ -9,6 +9,12 @@ {{ end }} {{ else }} + {{ if .Comparison.BinaryOmitted }} +
+ {{ i "triangle-alert" "w-4 h-4 flex-shrink-0 mt-0.5" }} + Some binary files are too large to carry in this patch. The pull request will open, but merging it will leave those files untouched. +
+ {{ end }} {{ $commits := .Comparison.FormatPatch }} {{ if $commits }}
diff --git a/appview/pulls/create.go b/appview/pulls/create.go index 2416c561..3aeb36f2 100644 --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -68,6 +68,10 @@ func (s *Pulls) handleBranchBasedPull( return } + if comparison.BinaryOmitted { + l.Warn("knot left binary payloads out of the compare, so this patch won't apply cleanly", "knot", repo.Knot, "repo", repo.RepoIdentifier()) + } + sourceRev := comparison.Rev2 patch := comparison.FormatPatchRaw combined := comparison.CombinedPatchRaw @@ -180,6 +184,10 @@ func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo return } + if comparison.BinaryOmitted { + l.Warn("knot left binary payloads out of the compare, so this patch won't apply cleanly", "knot", fork.Knot, "repo", fork.RepoIdentifier(), "hidden_ref", hiddenRef) + } + sourceRev := comparison.Rev2 patch := comparison.FormatPatchRaw combined := comparison.CombinedPatchRaw diff --git a/knot2/crates/knot-git/src/base85.rs b/knot2/crates/knot-git/src/base85.rs new file mode 100644 index 00000000..218b377d --- /dev/null +++ b/knot2/crates/knot-git/src/base85.rs @@ -0,0 +1,154 @@ +use std::sync::LazyLock; + +const MARKERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +const BYTES_PER_LINE: usize = MARKERS.len(); + +const ALPHABET: &[u8] = + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; + +static DIGITS: LazyLock<[Option; 256]> = LazyLock::new(|| { + std::array::from_fn(|byte| { + ALPHABET + .iter() + .position(|&candidate| candidate as usize == byte) + .map(|digit| digit as u8) + }) +}); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Malformed; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LineLength(u8); + +impl LineLength { + fn of(bytes: usize) -> Option { + bytes + .checked_sub(1) + .filter(|index| *index < MARKERS.len()) + .map(|index| Self(index as u8)) + } + + fn from_marker(marker: u8) -> Option { + MARKERS + .iter() + .position(|&candidate| candidate == marker) + .map(|index| Self(index as u8)) + } + + fn marker(self) -> char { + MARKERS[self.0 as usize] as char + } + + fn get(self) -> usize { + self.0 as usize + 1 + } +} + +pub(crate) fn encode(packed: &[u8], out: &mut String) { + out.reserve(encoded_len(packed.len() as u64) as usize); + packed.chunks(BYTES_PER_LINE).for_each(|chunk| { + let length = LineLength::of(chunk.len()) + .expect("chunking by the line width yields 1..=BYTES_PER_LINE bytes"); + out.push(length.marker()); + chunk.chunks(4).for_each(|group| { + let word = group + .iter() + .fold(0u32, |acc, &byte| (acc << 8) | byte as u32) + << (8 * (4 - group.len())); + (0..5).rev().for_each(|power| { + let digit = (word / 85u32.pow(power)) % 85; + out.push(ALPHABET[digit as usize] as char); + }); + }); + out.push('\n'); + }); +} + +pub(crate) fn encoded_len(packed: u64) -> u64 { + packed.div_ceil(4) * 5 + packed.div_ceil(BYTES_PER_LINE as u64) * 2 +} + +pub(crate) fn decode_line(line: &str, out: &mut Vec) -> Result<(), Malformed> { + let (marker, data) = line.as_bytes().split_first().ok_or(Malformed)?; + let length = LineLength::from_marker(*marker).ok_or(Malformed)?; + if data.len() != length.get().div_ceil(4) * 5 { + return Err(Malformed); + } + data.chunks(5).enumerate().try_for_each(|(group, chunk)| { + let word = chunk + .iter() + .try_fold(0u64, |acc, &character| { + DIGITS[character as usize].map(|digit| acc * 85 + digit as u64) + }) + .filter(|&word| word <= u32::MAX as u64) + .ok_or(Malformed)?; + let take = (length.get() - group * 4).min(4); + out.extend_from_slice(&(word as u32).to_be_bytes()[..take]); + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encoding_round_trips_at_every_payload_length() { + (0..=260usize).for_each(|len| { + let bytes: Vec = (0..len) + .map(|index| (index as u8).wrapping_mul(37) ^ 0x5a) + .collect(); + let mut encoded = String::new(); + encode(&bytes, &mut encoded); + assert_eq!( + encoded_len(len as u64), + encoded.len() as u64, + "encoded length of a {len} byte payload" + ); + let decoded = encoded.lines().fold(Vec::new(), |mut out, line| { + decode_line(line, &mut out).unwrap(); + out + }); + assert_eq!(decoded, bytes, "round trip of a {len} byte payload"); + }); + } + + #[test] + fn line_markers_map_both_ways() { + (1..=BYTES_PER_LINE).for_each(|len| { + let length = LineLength::of(len).unwrap(); + assert_eq!( + LineLength::from_marker(length.marker() as u8), + Some(length), + "marker for a line of {len} bytes" + ); + }); + assert_eq!(LineLength::of(0), None); + assert_eq!(LineLength::of(BYTES_PER_LINE + 1), None); + assert_eq!(LineLength::of(1).unwrap().marker(), 'A'); + assert_eq!(LineLength::of(26).unwrap().marker(), 'Z'); + assert_eq!(LineLength::of(27).unwrap().marker(), 'a'); + assert_eq!(LineLength::of(BYTES_PER_LINE).unwrap().marker(), 'z'); + let mut encoded = String::new(); + encode(&[0u8; BYTES_PER_LINE + 1], &mut encoded); + assert_eq!( + encoded.lines().map(|line| &line[..1]).collect::>(), + vec!["z", "A"], + "a payload past the line width gets a second marker" + ); + } + + #[test] + fn the_marker_sets_the_line_length_and_anything_else_is_malformed() { + let mut out = Vec::new(); + decode_line("D00000", &mut out).unwrap(); + decode_line("B00000", &mut out).unwrap(); + assert_eq!(out, vec![0, 0, 0, 0, 0, 0]); + assert_eq!(decode_line("D0000", &mut Vec::new()), Err(Malformed)); + assert_eq!(decode_line("D0\"000", &mut Vec::new()), Err(Malformed)); + assert_eq!(decode_line("?00000", &mut Vec::new()), Err(Malformed)); + assert_eq!(decode_line("", &mut Vec::new()), Err(Malformed)); + } +} diff --git a/knot2/crates/knot-git/src/lib.rs b/knot2/crates/knot-git/src/lib.rs index c27746a9..83fbd753 100644 --- a/knot2/crates/knot-git/src/lib.rs +++ b/knot2/crates/knot-git/src/lib.rs @@ -1,4 +1,5 @@ mod archive; +mod base85; mod bitmap; mod error; #[cfg(feature = "instrument")] @@ -22,8 +23,8 @@ pub use objects::{ ShallowPlan, Tree, TreeDepth, TreeEntry, Wants, }; pub use patch::{ - FilePatch, Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES, PatchRange, - PatchStatus, + BinaryBudget, BinaryDiff, BinarySizes, FilePatch, Hunk, HunkLine, LineCount, LineNumber, + LineOp, MAX_DIFF_BLOB_BYTES, PatchBody, PatchRange, PatchStatus, }; pub use patch_apply::{ ApplyError, ApplyOutcome, Conflict, ConflictReason, NewCommit, PatchApplier, StagedAction, @@ -31,7 +32,7 @@ pub use patch_apply::{ }; pub use patch_parse::{ FileIntent, MailPatch, ParsedFile, PatchParseError, PatchPayload, is_format_patch, - parse_mailbox, parse_mailbox_bounded, parse_patch, parse_patch_bounded, + parse_mailbox, parse_mailbox_bounded, parse_patch, parse_patch_bounded, quote_path, }; pub use reads::{ AnnotatedTag, BranchInfo, BranchTip, LastCommit, LogLimit, LogSkip, PathEntry, SizedEntry, diff --git a/knot2/crates/knot-git/src/patch.rs b/knot2/crates/knot-git/src/patch.rs index a039e7d2..e9f7be9a 100644 --- a/knot2/crates/knot-git/src/patch.rs +++ b/knot2/crates/knot-git/src/patch.rs @@ -1,15 +1,22 @@ use std::convert::Infallible; +use std::fmt::Write as _; +use std::io::Write; use std::ops::ControlFlow; +use flate2::Compression; +use flate2::write::ZlibEncoder; use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader}; use gix::diff::blob::{Algorithm, Diff, InternedInput, UnifiedDiff}; use knot_types::{ChangedFiles, ChangedFilesBudget, Listing, Oid, RepoPath}; +use crate::base85; use crate::error::{GitError, backend}; use crate::objects::EntryKind; use crate::repo::Repo; const BINARY_SNIFF_BYTES: usize = 8000; +const BLOCK_HEADER_MAX: usize = "literal 18446744073709551615\n".len(); +const BINARY_PATCH_HEADER: &str = "GIT binary patch\n"; pub const MAX_DIFF_BLOB_BYTES: u64 = 25 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,6 +81,113 @@ pub enum PatchStatus { Modified, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BinarySizes { + pub old: u64, + pub new: u64, +} + +impl BinarySizes { + fn of(old: &[u8], new: &[u8]) -> Self { + Self { + old: old.len() as u64, + new: new.len() as u64, + } + } + + fn wire_bound(self) -> u64 { + let block = |inflated: u64| { + let deflated = inflated + .saturating_add(inflated.div_ceil(8)) + .saturating_add(inflated.div_ceil(64)) + .saturating_add(11); + base85::encoded_len(deflated).saturating_add(BLOCK_HEADER_MAX as u64 + 1) + }; + block(self.old) + .saturating_add(block(self.new)) + .saturating_add(BINARY_PATCH_HEADER.len() as u64) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryBudget { + Omit, + Spend { remaining: u64, omitted: bool }, +} + +impl BinaryBudget { + pub fn new(bytes: u64) -> Self { + Self::Spend { + remaining: bytes, + omitted: false, + } + } + + pub fn omitted(self) -> bool { + matches!(self, Self::Spend { omitted: true, .. }) + } + + fn admit(&mut self, sizes: BinarySizes) -> bool { + match self { + Self::Omit => false, + Self::Spend { remaining, omitted } => match remaining.checked_sub(sizes.wire_bound()) { + Some(rest) => { + *remaining = rest; + true + } + None => { + *omitted = true; + false + } + }, + } + } +} + +fn literal_block(content: &[u8], out: &mut String) -> Result<(), GitError> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(content).map_err(backend)?; + writeln!(out, "literal {}", content.len()).expect("formatting into a String never fails"); + base85::encode(&encoder.finish().map_err(backend)?, out); + out.push('\n'); + Ok(()) +} + +fn encode_binary(old: &[u8], new: &[u8]) -> Result { + let mut text = String::from(BINARY_PATCH_HEADER); + literal_block(new, &mut text)?; + literal_block(old, &mut text)?; + Ok(BinaryDiff::Encoded { + sizes: BinarySizes::of(old, new), + text, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BinaryDiff { + Encoded { sizes: BinarySizes, text: String }, + Omitted(BinarySizes), + Unchanged(u64), +} + +impl BinaryDiff { + pub fn sizes(&self) -> BinarySizes { + match self { + Self::Encoded { sizes, .. } | Self::Omitted(sizes) => *sizes, + Self::Unchanged(bytes) => BinarySizes { + old: *bytes, + new: *bytes, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PatchBody { + Text(Vec), + Binary(BinaryDiff), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct FilePatch { pub status: PatchStatus, @@ -82,8 +196,20 @@ pub struct FilePatch { pub new_oid: Oid, pub old_kind: Option, pub new_kind: Option, - pub is_binary: bool, - pub hunks: Vec, + pub body: PatchBody, +} + +impl FilePatch { + pub fn is_binary(&self) -> bool { + matches!(self.body, PatchBody::Binary(_)) + } + + pub fn hunks(&self) -> &[Hunk] { + match &self.body { + PatchBody::Text(hunks) => hunks, + PatchBody::Binary(_) => &[], + } + } } fn is_binary(content: &[u8]) -> bool { @@ -169,28 +295,45 @@ impl Side { } } +fn subproject_line(oid: Oid) -> Vec { + format!("Subproject commit {}\n", oid.to_hex()).into_bytes() +} + impl Repo { fn patch_content(&self, side: &Side) -> Result, GitError> { match side { Side::Absent => Ok(Vec::new()), Side::Present { oid, kind } => match kind { - EntryKind::Commit => { - Ok(format!("Subproject commit {}\n", oid.to_hex()).into_bytes()) - } + EntryKind::Commit => Ok(subproject_line(*oid)), EntryKind::Tree => Ok(Vec::new()), _ => self.read_blob(*oid), }, } } - fn side_within_diff_budget(&self, side: &Side) -> Result { - match side { + fn sides_past_diff_budget( + &self, + old: &Side, + new: &Side, + ) -> Result, GitError> { + let size = |side: &Side| match side { + Side::Absent + | Side::Present { + kind: EntryKind::Tree, + .. + } => Ok(None), Side::Present { oid, - kind: EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link, - } => Ok(self.blob_size(*oid)? <= MAX_DIFF_BLOB_BYTES), - _ => Ok(true), - } + kind: EntryKind::Commit, + } => Ok(Some(subproject_line(*oid).len() as u64)), + Side::Present { oid, .. } => self.blob_size(*oid).map(Some), + }; + let (old, new) = (size(old)?, size(new)?); + let past = |bytes: Option| bytes.is_some_and(|bytes| bytes > MAX_DIFF_BLOB_BYTES); + Ok((past(old) || past(new)).then(|| BinarySizes { + old: old.unwrap_or(0), + new: new.unwrap_or(0), + })) } fn file_patch( @@ -199,20 +342,31 @@ impl Repo { path: RepoPath, old: Side, new: Side, + budget: &mut BinaryBudget, ) -> Result { - let within_budget = - self.side_within_diff_budget(&old)? && self.side_within_diff_budget(&new)?; - let (binary, hunks) = match within_budget { - false => (true, Vec::new()), - true => { + let same_content = matches!( + (&old, &new), + (Side::Present { oid: before, .. }, Side::Present { oid: after, .. }) + if before == after + ); + let body = match self.sides_past_diff_budget(&old, &new)? { + Some(sizes) => PatchBody::Binary(BinaryDiff::Omitted(sizes)), + None => { let old_content = self.patch_content(&old)?; let new_content = self.patch_content(&new)?; - let binary = is_binary(&old_content) || is_binary(&new_content); - let hunks = match binary { - true => Vec::new(), - false => text_hunks(&old_content, &new_content)?, - }; - (binary, hunks) + match is_binary(&old_content) || is_binary(&new_content) { + true => { + let sizes = BinarySizes::of(&old_content, &new_content); + PatchBody::Binary(match same_content { + true => BinaryDiff::Unchanged(sizes.new), + false => match budget.admit(sizes) { + true => encode_binary(&old_content, &new_content)?, + false => BinaryDiff::Omitted(sizes), + }, + }) + } + false => PatchBody::Text(text_hunks(&old_content, &new_content)?), + } } }; Ok(FilePatch { @@ -222,8 +376,7 @@ impl Repo { new_oid: new.oid(self.object_format().null_oid()), old_kind: old.kind(), new_kind: new.kind(), - is_binary: binary, - hunks, + body, }) } @@ -296,7 +449,11 @@ impl Repo { } } - pub fn commit_patches(&self, range: PatchRange) -> Result, GitError> { + pub fn commit_patches( + &self, + range: PatchRange, + budget: &mut BinaryBudget, + ) -> Result, GitError> { let (old_tree, new_tree) = self.diff_trees(range)?; let mut sides: Vec<(PatchStatus, String, Side, Side)> = Vec::new(); old_tree @@ -383,8 +540,56 @@ impl Repo { .map(|(status, path, old, new)| { let path = RepoPath::new(path).map_err(|error| GitError::Decode(error.to_string()))?; - self.file_patch(status, path, old, new) + self.file_patch(status, path, old, new, budget) }) .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_wire_bound_covers_every_byte_the_patch_writes() { + let payload = |len: usize, fill: fn(usize) -> u8| (0..len).map(fill).collect::>(); + [0usize, 1, 3, 4, 51, 52, 53, 1000, 65_536] + .into_iter() + .flat_map(|len| { + let noise = payload(len, |index| (index as u8).wrapping_mul(37) ^ 0x5a); + [(payload(len, |_| 0), noise.clone()), (noise, Vec::new())] + }) + .for_each(|(old, new)| { + let BinaryDiff::Encoded { sizes, text } = encode_binary(&old, &new).unwrap() else { + panic!("encode_binary returns Encoded for every payload"); + }; + assert!( + text.len() as u64 <= sizes.wire_bound(), + "payload of {} bytes: expected at most {}, wrote {}", + old.len().max(new.len()), + sizes.wire_bound(), + text.len() + ); + }); + } + + #[test] + fn the_budget_reports_the_first_payload_it_refuses() { + let sizes = BinarySizes { old: 0, new: 4096 }; + let mut budget = BinaryBudget::new(sizes.wire_bound()); + assert!(budget.admit(sizes)); + assert!( + !budget.omitted(), + "omitted is false while admit returns true" + ); + assert!(!budget.admit(sizes)); + assert!(budget.omitted(), "omitted is true once admit returns false"); + + let mut omit = BinaryBudget::Omit; + assert!(!omit.admit(sizes)); + assert!( + !omit.omitted(), + "omitted is false under Omit, where admit always returns false" + ); + } +} diff --git a/knot2/crates/knot-git/src/patch_parse.rs b/knot2/crates/knot-git/src/patch_parse.rs index de202c2a..93614e97 100644 --- a/knot2/crates/knot-git/src/patch_parse.rs +++ b/knot2/crates/knot-git/src/patch_parse.rs @@ -1,9 +1,9 @@ use std::io::Read; -use std::sync::LazyLock; use base64::Engine; use knot_types::{AuthorName, Email, Oid}; +use crate::base85; use crate::objects::{CommitChangeId, EntryKind}; use crate::patch::{Hunk, HunkLine, LineCount, LineNumber, LineOp, MAX_DIFF_BLOB_BYTES}; @@ -200,6 +200,35 @@ 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()) @@ -224,15 +253,23 @@ fn diff_paths(rest: &str) -> Option<(String, String)> { let (new, _) = take_path_token(after.strip_prefix(' ')?)?; Some((strip_level(&old), strip_level(&new))) } - false => { - let split = rest.rfind(" b/")?; - let old = rest.get(..split)?.strip_prefix("a/")?; - let new = rest.get(split + 3..)?; - Some((old.to_string(), new.to_string())) - } + false => unquoted_diff_paths(rest), } } +fn unquoted_diff_paths(rest: &str) -> Option<(String, String)> { + let split_at = |at: usize| { + let old = rest.get(..at)?.strip_prefix("a/")?; + let new = rest.get(at + " b/".len()..)?; + Some((old, new)) + }; + rest.match_indices(" b/") + .filter_map(|(at, _)| split_at(at)) + .find(|(old, new)| old == new) + .or_else(|| split_at(rest.rfind(" b/")?)) + .map(|(old, new)| (old.to_string(), new.to_string())) +} + fn take_path_token(rest: &str) -> Option<(String, &str)> { match rest.strip_prefix('"') { Some(inner) => { @@ -248,7 +285,7 @@ fn take_path_token(rest: &str) -> Option<(String, &str)> { } fn full_oid(hex: &str) -> Option { - (hex.len() == 40).then(|| Oid::from_hex(hex).ok()).flatten() + Oid::from_hex(hex).ok() } fn parse_hunk_header(line: &str) -> Option<(LineNumber, LineCount, LineNumber, LineCount)> { @@ -344,46 +381,6 @@ fn parse_hunks(cursor: &mut Cursor<'_>, budget: &mut Budget) -> Result .collect() } -static BASE85: LazyLock<[i16; 256]> = LazyLock::new(|| { - const ALPHABET: &[u8] = - b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; - std::array::from_fn(|byte| { - ALPHABET - .iter() - .position(|&c| c as usize == byte) - .map(|digit| digit as i16) - .unwrap_or(-1) - }) -}); - -fn decode_base85_line(line: &str, out: &mut Vec) -> Result<(), PatchParseError> { - let bad = || malformed("bad base85 line in binary patch"); - let (len_char, data) = line.as_bytes().split_first().ok_or_else(bad)?; - let line_len = match len_char { - b'A'..=b'Z' => (len_char - b'A' + 1) as usize, - b'a'..=b'z' => (len_char - b'a' + 27) as usize, - _ => return Err(bad()), - }; - if data.len() != line_len.div_ceil(4) * 5 { - return Err(bad()); - } - data.chunks(5) - .enumerate() - .try_for_each(|(group, chunk)| -> Result<(), PatchParseError> { - let acc = chunk - .iter() - .try_fold(0u64, |acc, &c| { - let digit = BASE85[c as usize]; - (digit >= 0).then(|| acc * 85 + digit as u64) - }) - .filter(|&acc| acc <= u32::MAX as u64) - .ok_or_else(bad)?; - let take = (line_len - group * 4).min(4); - out.extend_from_slice(&(acc as u32).to_be_bytes()[..take]); - Ok(()) - }) -} - fn parse_binary_block( cursor: &mut Cursor<'_>, budget: &mut Budget, @@ -414,7 +411,10 @@ fn parse_binary_block( .is_some_and(|line| !line.is_empty()) .then(|| cursor.next().expect("peeked line is present")) }) - .try_for_each(|line| decode_base85_line(line, &mut packed))?; + .try_for_each(|line| { + base85::decode_line(line, &mut packed) + .map_err(|_| malformed("invalid base85 line in binary patch")) + })?; cursor.next(); let mut inflated: Vec = Vec::new(); flate2::read::ZlibDecoder::new(packed.as_slice()) @@ -970,6 +970,99 @@ mod tests { assert!(unquote("\"a/broken").is_err()); } + #[test] + fn a_plain_path_stays_bare_and_a_quoted_one_unquotes_back() { + [ + "a/reef.txt", + "a/~tilde", + "a/{brace}", + "a/sp ace.txt", + "a/b/ b/c", + ] + .into_iter() + .for_each(|plain| { + assert_eq!(quote_path(plain), plain, "git leaves {plain} bare too"); + }); + [ + "a/quote\".txt", + "a/back\\slash", + "a/tab\there", + "a/new\nline", + "a/é", + "a/\u{7f}del", + ] + .into_iter() + .for_each(|awkward| { + let quoted = quote_path(awkward); + assert!(quoted.starts_with('"'), "{awkward} must come out quoted"); + assert_eq!( + unquote("ed).unwrap(), + awkward, + "{awkward} must unquote back to itself" + ); + }); + } + + #[test] + fn diff_paths_splits_a_header_whose_path_holds_the_separator() { + [ + ("a/b/ b/c.bin b/b/ b/c.bin", "b/ b/c.bin", "b/ b/c.bin"), + ( + "\"a/quote\\\".bin\" \"b/quote\\\".bin\"", + "quote\".bin", + "quote\".bin", + ), + ( + "a/old name.txt b/new name.txt", + "old name.txt", + "new name.txt", + ), + ] + .into_iter() + .for_each(|(header, old, new)| { + assert_eq!( + diff_paths(header), + Some((old.to_string(), new.to_string())), + "the a-side and b-side must agree on the split: {header}" + ); + }); + } + + #[test] + fn a_binary_file_named_around_the_separator_keeps_its_path() { + let patch = concat!( + "diff --git a/b/ b/c.bin b/b/ b/c.bin\n", + "index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644\n", + "GIT binary patch\n", + "literal 4\n", + "LcmZRms;UA20^k8}\n", + "\n", + "literal 5\n", + "Mcmb RepoPath { RepoPath::new(path).unwrap() } +fn patches_of( + bare: &Repo, + base: Option, + head: Oid, + budget: &mut BinaryBudget, +) -> Vec { + bare.commit_patches(knot_git::PatchRange { base, head }, budget) + .unwrap() +} + +fn named<'a>(patches: &'a [FilePatch], path: &str) -> &'a FilePatch { + patches + .iter() + .find(|patch| patch.path.as_str() == path) + .unwrap() +} + +fn binary_diff(patch: &FilePatch) -> &BinaryDiff { + match &patch.body { + PatchBody::Binary(diff) => diff, + PatchBody::Text(_) => panic!("expected a binary patch for {}, found text", patch.path), + } +} + mod common; use common::{commit_file, contains, git_ok as git}; @@ -289,19 +316,14 @@ fn typed_reads_over_a_rich_repo() { git(work, &["log", "-1", "--format=%H", "--", "src/lib.rs"]) ); - let patches = bare - .commit_patches(knot_git::PatchRange { - base: Some(parent), - head, - }) - .unwrap(); + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::Omit); assert_eq!(patches.len(), 1); let patch = &patches[0]; assert_eq!(patch.path.as_str(), "src/lib.rs"); assert_eq!(patch.status, knot_git::PatchStatus::Modified); - assert!(!patch.is_binary); - assert_eq!(patch.hunks.len(), 1); - let hunk = &patch.hunks[0]; + assert!(!patch.is_binary()); + assert_eq!(patch.hunks().len(), 1); + let hunk = &patch.hunks()[0]; assert_eq!( ( hunk.old_start.get(), @@ -321,20 +343,15 @@ fn typed_reads_over_a_rich_repo() { vec!["pub fn nel() {}\n", "pub fn teq() {}\n"] ); - let initial = bare - .commit_patches(knot_git::PatchRange { - base: None, - head: root, - }) - .unwrap(); + let initial = patches_of(&bare, None, root, &mut BinaryBudget::Omit); assert_eq!(initial.len(), 1); assert_eq!(initial[0].status, knot_git::PatchStatus::Added); assert_eq!( - initial[0].hunks[0].old_start.get(), + initial[0].hunks()[0].old_start.get(), 0, "added file hunk starts at -0,0" ); - assert_eq!(initial[0].hunks[0].old_lines.get(), 0); + assert_eq!(initial[0].hunks()[0].old_lines.get(), 0); let tag_commit = bare.peel_to_commit(tag_object).unwrap(); assert_eq!( @@ -595,23 +612,29 @@ fn extended_history_reads() { let bare = layout.open(&did).unwrap(); let head = Oid::from_hex(&git(work, &["rev-parse", "HEAD"])).unwrap(); let parent = Oid::from_hex(&git(work, &["rev-parse", "HEAD~1"])).unwrap(); - let patches = bare - .commit_patches(knot_git::PatchRange { - base: Some(parent), - head, - }) - .unwrap(); - let binary = patches - .iter() - .find(|patch| patch.path.as_str() == "blob.bin") - .unwrap(); - assert!(binary.is_binary); - assert!(binary.hunks.is_empty()); - let noeol = patches - .iter() - .find(|patch| patch.path.as_str() == "noeol.txt") + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::new(1024)); + let blob = binary_diff(named(&patches, "blob.bin")); + assert!( + matches!(blob, BinaryDiff::Encoded { .. }), + "a blob under the budget is encoded" + ); + let mut thin = BinaryBudget::new(5); + let thinned = patches_of(&bare, Some(parent), head, &mut thin); + let starved = binary_diff(named(&thinned, "blob.bin")); + assert!( + matches!(starved, BinaryDiff::Omitted(_)), + "a blob past the budget is omitted" + ); + assert!(thin.omitted(), "omitted is true once a blob is left out"); + assert_eq!( + [blob.sizes(), starved.sizes()].map(|sizes| (sizes.old, sizes.new)), + [(0, 6); 2], + "a new file has an empty pre-image, encoded or omitted" + ); + let last = named(&patches, "noeol.txt").hunks()[0] + .lines + .last() .unwrap(); - let last = noeol.hunks[0].lines.last().unwrap(); assert_eq!(last.text, b"no newline at end".to_vec()); } @@ -764,19 +787,19 @@ fn an_oversized_blob_diffs_as_binary_without_loading_it() { let bare = layout.open(&did).unwrap(); let head = Oid::from_hex(&git(&clone, &["rev-parse", "HEAD"])).unwrap(); let parent = Oid::from_hex(&git(&clone, &["rev-parse", "HEAD~1"])).unwrap(); - let patches = bare - .commit_patches(knot_git::PatchRange { - base: Some(parent), - head, - }) - .unwrap(); - let huge = patches - .iter() - .find(|patch| patch.path.as_str() == "huge.txt") - .unwrap(); + let patches = patches_of(&bare, Some(parent), head, &mut BinaryBudget::new(u64::MAX)); + let huge = named(&patches, "huge.txt"); assert!( - huge.is_binary, + huge.is_binary() && huge.hunks().is_empty(), "blob past diff budget falls back to binary instead of being loaded" ); - assert!(huge.hunks.is_empty()); + assert!( + matches!(binary_diff(huge), BinaryDiff::Omitted(_)), + "a blob nobody read has no bytes to encode, whatever the budget allows" + ); + assert_eq!( + binary_diff(huge).sizes().new, + knot_git::MAX_DIFF_BLOB_BYTES + 1, + "the size comes from the blob header the knot did read" + ); } diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 71bdd7b3..70c78581 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -129,6 +129,17 @@ impl Default for ByteLimits { } } +const BINARY_RESPONSE_SHARE: u64 = 4; +const BINARY_WIRE_COPIES: u64 = 3; + +impl ByteLimits { + pub fn binary_patch(self) -> knot_git::BinaryBudget { + knot_git::BinaryBudget::new( + self.response.get() as u64 / BINARY_RESPONSE_SHARE / BINARY_WIRE_COPIES, + ) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Budgets { pub tree_last_commit: TreeReadBudget, diff --git a/knot2/crates/knot-xrpc/src/patchtext.rs b/knot2/crates/knot-xrpc/src/patchtext.rs index 7fb94285..dfbf3216 100644 --- a/knot2/crates/knot-xrpc/src/patchtext.rs +++ b/knot2/crates/knot-xrpc/src/patchtext.rs @@ -1,9 +1,16 @@ -use knot_git::{Commit, FilePatch, Hunk, LineCount, LineNumber, LineOp, PatchStatus}; +use knot_git::{ + BinaryDiff, Commit, EntryKind, FilePatch, Hunk, LineCount, LineNumber, LineOp, PatchBody, + PatchStatus, quote_path, +}; use crate::wire::{entry_mode_octal, fold_subject, message_body, rfc2822}; const GRAPH_WIDTH: usize = 60; +fn entry_mode(kind: Option) -> String { + kind.map(entry_mode_octal).unwrap_or_default() +} + fn span(start: LineNumber, lines: LineCount) -> String { match lines.get() { 1 => format!("{}", start.get()), @@ -31,66 +38,58 @@ fn render_hunk(out: &mut String, hunk: &Hunk) { } fn render_file(out: &mut String, patch: &FilePatch) { - let (a, b) = (&patch.path, &patch.path); - out.push_str(&format!("diff --git a/{a} b/{b}\n")); + let old_side = quote_path(&format!("a/{}", patch.path)); + let new_side = quote_path(&format!("b/{}", patch.path)); + let index = format!( + "index {}..{}", + patch.old_oid.to_hex(), + patch.new_oid.to_hex() + ); + out.push_str(&format!("diff --git {old_side} {new_side}\n")); match patch.status { - PatchStatus::Added => { - let mode = patch.new_kind.map(entry_mode_octal).unwrap_or_default(); - out.push_str(&format!("new file mode {mode}\n")); - out.push_str(&format!( - "index {}..{}\n", - patch.old_oid.to_hex(), - patch.new_oid.to_hex() - )); + PatchStatus::Added => out.push_str(&format!( + "new file mode {}\n{index}\n", + entry_mode(patch.new_kind) + )), + PatchStatus::Deleted => out.push_str(&format!( + "deleted file mode {}\n{index}\n", + entry_mode(patch.old_kind) + )), + PatchStatus::Modified if patch.old_kind == patch.new_kind => { + out.push_str(&format!("{index} {}\n", entry_mode(patch.old_kind))) } - PatchStatus::Deleted => { - let mode = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); - out.push_str(&format!("deleted file mode {mode}\n")); + PatchStatus::Modified => { out.push_str(&format!( - "index {}..{}\n", - patch.old_oid.to_hex(), - patch.new_oid.to_hex() + "old mode {}\nnew mode {}\n", + entry_mode(patch.old_kind), + entry_mode(patch.new_kind) )); - } - PatchStatus::Modified => { - if patch.old_kind == patch.new_kind { - let mode = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); - out.push_str(&format!( - "index {}..{} {mode}\n", - patch.old_oid.to_hex(), - patch.new_oid.to_hex() - )); - } else { - let old = patch.old_kind.map(entry_mode_octal).unwrap_or_default(); - let new = patch.new_kind.map(entry_mode_octal).unwrap_or_default(); - out.push_str(&format!("old mode {old}\nnew mode {new}\n")); - out.push_str(&format!( - "index {}..{}\n", - patch.old_oid.to_hex(), - patch.new_oid.to_hex() - )); + match patch.old_oid == patch.new_oid { + true => {} + false => out.push_str(&format!("{index}\n")), } } } let old_label = match patch.status { - PatchStatus::Added => "/dev/null".to_string(), - _ => format!("a/{a}"), + PatchStatus::Added => "/dev/null", + _ => old_side.as_str(), }; let new_label = match patch.status { - PatchStatus::Deleted => "/dev/null".to_string(), - _ => format!("b/{b}"), + PatchStatus::Deleted => "/dev/null", + _ => new_side.as_str(), }; - if patch.is_binary { - out.push_str(&format!( + match &patch.body { + PatchBody::Binary(BinaryDiff::Encoded { text, .. }) => out.push_str(text), + PatchBody::Binary(BinaryDiff::Omitted(_)) => out.push_str(&format!( "Binary files {old_label} and {new_label} differ\n" - )); - return; - } - if patch.hunks.is_empty() { - return; + )), + PatchBody::Binary(BinaryDiff::Unchanged(_)) => {} + PatchBody::Text(hunks) if hunks.is_empty() => {} + PatchBody::Text(hunks) => { + out.push_str(&format!("--- {old_label}\n+++ {new_label}\n")); + hunks.iter().for_each(|hunk| render_hunk(out, hunk)); + } } - out.push_str(&format!("--- {old_label}\n+++ {new_label}\n")); - patch.hunks.iter().for_each(|hunk| render_hunk(out, hunk)); } pub(crate) fn render_patches(patches: &[FilePatch]) -> String { @@ -101,7 +100,7 @@ pub(crate) fn render_patches(patches: &[FilePatch]) -> String { } fn stat_counts(patch: &FilePatch) -> (usize, usize) { - patch.hunks.iter().fold((0, 0), |(added, deleted), hunk| { + patch.hunks().iter().fold((0, 0), |(added, deleted), hunk| { ( added + hunk.added().get() as usize, deleted + hunk.deleted().get() as usize, @@ -120,25 +119,27 @@ fn graph(added: usize, deleted: usize) -> String { } fn diffstat(patches: &[FilePatch]) -> String { - let width = patches + let named: Vec<(String, &FilePatch)> = patches .iter() - .map(|patch| patch.path.as_str().len()) - .max() - .unwrap_or(0); - let rows: String = patches + .map(|patch| (quote_path(patch.path.as_str()), patch)) + .collect(); + let width = named.iter().map(|(name, _)| name.len()).max().unwrap_or(0); + let rows: String = named .iter() - .map(|patch| { - if patch.is_binary { - format!(" {: format!(" {name: { + let sizes = binary.sizes(); format!( - " {: {} bytes\n", + sizes.old, sizes.new ) } + PatchBody::Text(_) => { + let (added, deleted) = stat_counts(patch); + let total = added + deleted; + format!(" {name: String { )); } summary.push('\n'); - let created: String = patches + let modes: String = named .iter() - .filter(|patch| patch.status == PatchStatus::Added) - .map(|patch| { - format!( - " create mode {} {}\n", - patch.new_kind.map(entry_mode_octal).unwrap_or_default(), - patch.path - ) - }) - .collect(); - let deleted_rows: String = patches - .iter() - .filter(|patch| patch.status == PatchStatus::Deleted) - .map(|patch| { - format!( - " delete mode {} {}\n", - patch.old_kind.map(entry_mode_octal).unwrap_or_default(), - patch.path - ) + .filter_map(|(name, patch)| { + let (old, new) = (entry_mode(patch.old_kind), entry_mode(patch.new_kind)); + match patch.status { + PatchStatus::Added => Some(format!(" create mode {new} {name}\n")), + PatchStatus::Deleted => Some(format!(" delete mode {old} {name}\n")), + PatchStatus::Modified if patch.old_kind != patch.new_kind => { + Some(format!(" mode change {old} => {new} {name}\n")) + } + PatchStatus::Modified => None, + } }) .collect(); - format!("{rows}{summary}{created}{deleted_rows}") + format!("{rows}{summary}{modes}") } pub(crate) fn render_format_patch(commit: &Commit, patches: &[FilePatch]) -> String { diff --git a/knot2/crates/knot-xrpc/src/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs index e5451142..78a24d58 100644 --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -13,8 +13,8 @@ use tower_http::services::ServeFile; use knot_cobs::RepoRef; use knot_git::{ - ArchiveFormat, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, SizedEntry, - is_public_ref, screens_reserved, + ArchiveFormat, BinaryBudget, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, + SizedEntry, is_public_ref, screens_reserved, }; use knot_index::{Coverage, Resolved}; use knot_runtime::{Clock, HttpTransport}; @@ -901,10 +901,13 @@ pub(crate) async fn repo_diff( let repo = open(&layout, &did)?; let target = commit_for(&repo, ¶ms.refspec)?; let commit = repo.find_commit(target)?; - let patches = repo.commit_patches(knot_git::PatchRange { - base: commit.parents.first().copied(), - head: target, - })?; + let patches = repo.commit_patches( + knot_git::PatchRange { + base: commit.parents.first().copied(), + head: target, + }, + &mut BinaryBudget::Omit, + )?; json( DiffOut { refspec: params.refspec.as_str().to_string(), @@ -939,6 +942,8 @@ struct CompareOut { 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( @@ -1009,6 +1014,8 @@ pub(crate) async fn repo_compare( } 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| { @@ -1069,10 +1076,13 @@ pub(crate) async fn repo_compare( let entries: Vec<(FormatPatchWire, String)> = commits .iter() .map(|commit| { - repo.commit_patches(knot_git::PatchRange { - base: commit.parents.first().copied(), - head: commit.id, - }) + repo.commit_patches( + knot_git::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) @@ -1080,14 +1090,20 @@ pub(crate) async fn repo_compare( }) .collect::, _>>() .map_err(compare_error)?; - let patch_raw: String = entries.iter().map(|(_, raw)| format!("{raw}\n")).collect(); + 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 (entries.len() >= 2, merge_base) { + let (combined_patch, combined_patch_raw) = match (commits.len() >= 2, merge_base) { (true, Some(merge_base)) => repo - .commit_patches(knot_git::PatchRange { - base: Some(merge_base), - head, - }) + .commit_patches( + knot_git::PatchRange { + base: Some(merge_base), + head, + }, + &mut combined_binary, + ) .ok() .map(|patches| { ( @@ -1107,6 +1123,8 @@ pub(crate) async fn repo_compare( patch_raw, combined_patch, combined_patch_raw, + binary_omitted: (series_binary.omitted() || combined_binary.omitted()) + .then_some(true), }, limit, ) diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index f0b42b24..dd367f48 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -3408,4 +3408,24 @@ mod legacy_admin_route { "past the burst the knot sheds the guess flood before it reaches the secret comparison, got {statuses:?}" ); } + + #[test] + fn every_wire_copy_of_an_embedded_payload_fits_a_quarter_of_the_response() { + let limits = crate::ByteLimits::default(); + assert_eq!(limits.response.get(), 5 * 1024 * 1024); + let response = limits.response.get() as u64; + assert_eq!( + limits.binary_patch(), + knot_git::BinaryBudget::new(response / 4 / 3), + "a compare serialises the series twice and the combined patch once" + ); + let per_pass = match limits.binary_patch() { + knot_git::BinaryBudget::Spend { remaining, .. } => remaining, + knot_git::BinaryBudget::Omit => panic!("binary_patch spends, it doesn't omit"), + }; + assert!( + per_pass * 3 <= response / 4, + "three copies of {per_pass} bytes must stay inside a quarter of {response}" + ); + } } diff --git a/knot2/crates/knot-xrpc/src/wire.rs b/knot2/crates/knot-xrpc/src/wire.rs index 87620638..1b3156dd 100644 --- a/knot2/crates/knot-xrpc/src/wire.rs +++ b/knot2/crates/knot-xrpc/src/wire.rs @@ -394,7 +394,7 @@ pub(crate) struct DiffWire { impl DiffWire { pub fn of(patch: &FilePatch) -> Self { let fragments: Vec = - patch.hunks.iter().map(TextFragmentWire::of).collect(); + patch.hunks().iter().map(TextFragmentWire::of).collect(); Self { name: DiffNameWire { old: match patch.status { @@ -407,7 +407,7 @@ impl DiffWire { }, }, text_fragments: (!fragments.is_empty()).then_some(fragments), - is_binary: patch.is_binary, + is_binary: patch.is_binary(), is_new: patch.status == PatchStatus::Added, is_delete: patch.status == PatchStatus::Deleted, is_copy: false, @@ -435,12 +435,12 @@ pub(crate) fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire let stat = DiffStatWire { insertions: patches .iter() - .flat_map(|patch| patch.hunks.iter()) + .flat_map(|patch| patch.hunks().iter()) .map(|hunk| hunk.added().get() as i64) .sum(), deletions: patches .iter() - .flat_map(|patch| patch.hunks.iter()) + .flat_map(|patch| patch.hunks().iter()) .map(|hunk| hunk.deleted().get() as i64) .sum(), files_changed: patches.len() as i64, @@ -558,7 +558,7 @@ pub(crate) struct FileWire { impl FileWire { pub fn of(patch: &FilePatch) -> Self { let fragments: Vec = - patch.hunks.iter().map(TextFragmentWire::of).collect(); + 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 { @@ -589,7 +589,7 @@ impl FileWire { new_oid_prefix: patch.new_oid.to_hex(), score: 0, text_fragments: (!fragments.is_empty()).then_some(fragments), - is_binary: patch.is_binary, + is_binary: patch.is_binary(), binary_fragment: None, reverse_binary_fragment: None, } diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index e34abf7b..f14c396e 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use std::collections::BTreeSet; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::sync::Arc; @@ -448,25 +449,13 @@ pub fn commit_file(work: &Path, file: &str, contents: &[u8], message: &str, when } pub fn seeded(world: &World, rkey: &str) -> (RepoDid, tempfile::TempDir) { - seeded_with_format(world, rkey, ObjectFormat::SHA1) -} - -pub fn seeded_with_format( - world: &World, - rkey: &str, - object_format: ObjectFormat, -) -> (RepoDid, tempfile::TempDir) { let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); - world.layout.create(&did).unwrap(); + let object_format = world.layout.create(&did).unwrap().object_format(); world.register(&did, rkey); let bare = world.layout.repo_path(&did).unwrap(); let work_dir = tempfile::tempdir().unwrap(); let work = work_dir.path(); - let init = match object_format == ObjectFormat::SHA256 { - true => vec!["init", "-q", "--object-format=sha256", "-b", "main"], - false => vec!["init", "-q", "-b", "main"], - }; - sh_git(work, &init); + sh_git(work, &init_args(object_format)); commit_file( work, "README.md", @@ -508,9 +497,16 @@ pub fn seeded_with_format( (did, work_dir) } +fn init_args(object_format: ObjectFormat) -> Vec<&'static str> { + match object_format == ObjectFormat::SHA256 { + true => vec!["init", "-q", "--object-format=sha256", "-b", "main"], + false => vec!["init", "-q", "-b", "main"], + } +} + pub fn empty_repo(world: &World, rkey: &str) -> (RepoDid, String, tempfile::TempDir) { let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); - world.layout.create(&did).unwrap(); + let object_format = world.layout.create(&did).unwrap().object_format(); world.register(&did, rkey); let bare = world .layout @@ -520,39 +516,75 @@ pub fn empty_repo(world: &World, rkey: &str) -> (RepoDid, String, tempfile::Temp .unwrap() .to_string(); let work_dir = tempfile::tempdir().unwrap(); - sh_git(work_dir.path(), &["init", "-q", "-b", "main"]); + sh_git(work_dir.path(), &init_args(object_format)); (did, bare, work_dir) } +fn shell_bytes(seed: u8) -> Vec { + let stream = std::iter::successors(Some(seed as u32), |state| { + Some(state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223)) + }) + .map(|state| (state >> 16) as u8); + std::iter::once(0).chain(stream).take(4096).collect() +} + +fn at(clock: &str) -> String { + format!("2026-06-01T{clock}+02:00") +} + pub fn seeded_feature_branch(world: &World, rkey: &str) -> (RepoDid, Oid, Oid) { let (did, bare, work_dir) = empty_repo(world, rkey); let work = work_dir.path(); - commit_file( - work, - "reef.txt", - b"one\ntwo\n", - "base", - "2026-06-01T12:30:00+02:00", - ); + let commits = |list: Vec<(&str, Vec, &str, &str)>| { + list.into_iter().for_each(|(file, bytes, message, minute)| { + commit_file(work, file, &bytes, message, &at(minute)) + }); + }; + commits(vec![ + ("reef.txt", b"one\ntwo\n".into(), "base", "12:30:00"), + ("shell.bin", shell_bytes(0), "add shell", "12:30:10"), + ("anchor.bin", shell_bytes(211), "add anchor", "12:30:20"), + ("hull.bin", shell_bytes(159), "add hull", "12:30:30"), + ]); sh_git(work, &["checkout", "-q", "-b", "feature"]); - commit_file( + std::fs::set_permissions( + work.join("hull.bin"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + sh_git_at(work, &at("12:30:40"), &["add", "-A"]); + sh_git_at( work, - "reef.txt", - b"one\nTWO\n", - "capitalize two\n\nbecause waves", - "2026-06-01T12:31:00+02:00", + &at("12:30:40"), + &["commit", "-q", "-m", "make hull runnable"], ); - commit_file( + commits(vec![ + ( + "reef.txt", + b"one\nTWO\n".into(), + "capitalize two\n\nbecause waves", + "12:31:00", + ), + ("kelp.txt", b"frond\n".into(), "add kelp", "12:31:10"), + ("shell.bin", shell_bytes(97), "reshape shell", "12:31:20"), + ("pearl.bin", vec![0, 255, 12, 0, 9], "add pearl", "12:31:30"), + ( + "deep water.bin", + shell_bytes(43), + "add deep water", + "12:31:40", + ), + ]); + std::fs::remove_file(work.join("anchor.bin")).unwrap(); + sh_git_at(work, &at("12:32:00"), &["add", "-A"]); + sh_git_at( work, - "kelp.txt", - b"frond\n", - "add kelp", - "2026-06-01T12:32:00+02:00", + &at("12:32:00"), + &["commit", "-q", "-m", "drop anchor"], ); sh_git(work, &["push", "-q", &bare, "main", "feature"]); - let main = Oid::from_hex(&sh_git(work, &["rev-parse", "main"])).unwrap(); - let feature = Oid::from_hex(&sh_git(work, &["rev-parse", "feature"])).unwrap(); - (did, main, feature) + let tip = |name: &str| Oid::from_hex(&sh_git(work, &["rev-parse", name])).unwrap(); + (did, tip("main"), tip("feature")) } pub async fn get_with_headers( diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index fbf529a1..01da15e9 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -10,14 +10,13 @@ use http::{HeaderMap, StatusCode, header}; use tokio_tungstenite::tungstenite; use knot_events::{EventCursor, GitRefUpdate}; -use knot_types::{AccountDid, ObjectFormat, Oid, OwnerDid, RepoDid}; +use knot_types::{AccountDid, Oid, OwnerDid, RepoDid}; use knot_xrpc::{ArchiveLimit, ResponseLimit}; use common::{ OWNER, World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, post_authed, - post_json, ref_names, repo_dids, seeded, seeded_feature_branch, seeded_with_format, sh_git, - sh_git_at, + post_json, ref_names, repo_dids, seeded, seeded_feature_branch, sh_git, sh_git_at, }; #[tokio::test] @@ -790,7 +789,7 @@ async fn archive_link_advertises_the_prefix_it_served() { #[tokio::test] async fn archive_serves_a_sha256_repo_with_a_stable_etag() { let world = World::sha256(); - let (did, _work) = seeded_with_format(&world, "nautilus", ObjectFormat::SHA256); + let (did, _work) = seeded(&world, "nautilus"); let (status, headers, full) = get( &world, @@ -1588,51 +1587,100 @@ async fn languages_omit_files_with_zero_size() { #[tokio::test] async fn a_compare_patch_round_trips_through_merge_check() { - let world = World::new(); - let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); - let registered = RepoDid::new("did:plc:periwinklefixture").unwrap(); + compare_round_trip(World::new()).await; +} + +#[tokio::test] +async fn a_sha256_compare_patch_round_trips_through_merge_check() { + compare_round_trip(World::sha256()).await; +} + +async fn compare_round_trip(world: World) { + let (did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); let compared = get_json( &world, - &format!( - "/xrpc/sh.tangled.repo.compare?repo={registered}&rev1={main_sha}&rev2={feature_sha}" - ), + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={main_sha}&rev2={feature_sha}"), ) .await; let patch = compared["patch"].as_str().unwrap(); - - let (status, check) = post_json( - &world, - "/xrpc/sh.tangled.repo.mergeCheck", - serde_json::json!({ - "repo": registered, - "branch": "main", - "patch": patch, - }), - ) - .await; - assert_eq!(status, StatusCode::OK); + let combined = compared["combined_patch_raw"].as_str().unwrap(); + + let checks = stream::iter([("main", patch), ("main", combined), ("feature", patch)]) + .then(|(branch, candidate)| { + post_json( + &world, + "/xrpc/sh.tangled.repo.mergeCheck", + serde_json::json!({ + "repo": did, + "branch": branch, + "patch": candidate, + }), + ) + }) + .collect::>() + .await; assert_eq!( - check["is_conflicted"], - serde_json::Value::Bool(false), - "knot's own compare output must pass its own merge check: {check}" + checks + .iter() + .map(|(status, check)| (*status, check["is_conflicted"].clone())) + .collect::>(), + [false, false, true] + .map(|conflicted| (StatusCode::OK, serde_json::Value::Bool(conflicted))) + .to_vec(), + "main takes both patches and feature already has them: {checks:?}" + ); + + [ + "GIT binary patch", + " shell.bin | Bin 4096 -> 4096 bytes\n", + "deleted file mode 100644\n", + " delete mode 100644 anchor.bin\n", + "diff --git a/deep water.bin b/deep water.bin\n", + "old mode 100644\nnew mode 100755\n", + " mode change 100644 => 100755 hull.bin\n", + " hull.bin | Bin\n", + ] + .into_iter() + .for_each(|needle| { + assert!( + patch.contains(needle), + "the format patch is missing {needle:?}: {patch}" + ) + }); + assert!( + combined.contains("GIT binary patch"), + "the combined patch is missing its binary payloads: {combined}" + ); + assert!( + !patch.contains("new mode 100755\nindex "), + "a mode change leaves both sides at the same oid, so git prints no index line: {patch}" + ); + assert_eq!( + compared.get("binary_omitted"), + None, + "binary_omitted is absent when every payload was embedded: {compared}" ); - let (status, stale) = post_json( - &world, - "/xrpc/sh.tangled.repo.mergeCheck", - serde_json::json!({ - "repo": registered, - "branch": "feature", - "patch": patch, - }), - ) - .await; - assert_eq!(status, StatusCode::OK); + let applied = tempfile::tempdir().unwrap(); + let bare = world.layout.repo_path(&did).unwrap(); + sh_git( + applied.path(), + &["clone", "-q", bare.to_str().unwrap(), "."], + ); + sh_git(applied.path(), &["checkout", "-q", "main"]); + std::fs::write(applied.path().join("knot.patch"), patch).unwrap(); + sh_git(applied.path(), &["am", "knot.patch"]); assert_eq!( - stale["is_conflicted"], - serde_json::Value::Bool(true), - "re-applying an already-landed patch must conflict: {stale}" + sh_git(applied.path(), &["rev-parse", "HEAD^{tree}"]), + sh_git(applied.path(), &["rev-parse", "origin/feature^{tree}"]), + "git am of the knot's own patch rebuilds the tree the branch already has" + ); + assert!( + !applied.path().join("anchor.bin").exists() + && applied.path().join("deep water.bin").exists() + && sh_git(applied.path(), &["ls-files", "-s", "hull.bin"]).starts_with("100755 "), + "git am dropped anchor.bin, wrote deep water.bin and kept the exec bit on hull.bin" ); } @@ -2047,7 +2095,7 @@ async fn bad_post_bodies_are_invalid_request() { } #[tokio::test] -async fn merge_applies_a_plain_patch_under_the_supplied_author() { +async fn merge_applies_a_patch_under_the_supplied_author() { let world = World::new(); let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "mussel"); let registered = RepoDid::new("did:plc:musselfixture").unwrap(); diff --git a/types/repo.go b/types/repo.go index f10b7eae..58bd999e 100644 --- a/types/repo.go +++ b/types/repo.go @@ -39,6 +39,7 @@ type RepoFormatPatchResponse struct { FormatPatchRaw string `json:"patch,omitempty"` CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` + BinaryOmitted bool `json:"binary_omitted,omitempty"` } type TagReference struct { -- 2.51.2