From 1bed3412b9db5959fcafbf4802ad897c33abc58e Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Wed, 15 Apr 2026 18:51:51 -0400 Subject: [PATCH 1/8] test(patch): panic on non-UTF-8 escaped filename Octal escape \377 decodes to 0xFF, which is not valid UTF-8. When parsing into `Patch<'_, str>`, `convert_cow_to_str` panics via `unwrap()` instead of returning a parse error. See https://github.com/bmwill/diffy/pull/59#r3089024322 --- src/patch/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/patch/tests.rs b/src/patch/tests.rs index 19671fd5..ded7a7a1 100644 --- a/src/patch/tests.rs +++ b/src/patch/tests.rs @@ -618,6 +618,25 @@ fn plain_filename_roundtrip() { assert_eq!(p.modified(), p2.modified()); } +// Octal escape \377 decodes to 0xFF, which is not valid UTF-8. +// When parsing into `Patch<'_, str>`, `convert_cow_to_str` panics +// via `unwrap()` instead of returning a parse error. +// +// This documents the pre-existing bug that the reviewer flagged: +// https://github.com/bmwill/diffy/pull/59#r3089024322 +#[test] +#[should_panic] +fn non_utf8_escaped_filename_panics_on_str_parse() { + let s = r#"\ +--- "a/foo\377" ++++ "b/foo\377" +@@ -1 +1 @@ +-x ++y +"#; + let _ = parse(s); +} + mod error_display { use crate::patch::error::ParsePatchErrorKind; use crate::Patch; From 4ea1e343eafa336f1b2560fbd72fcbda38893220 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Wed, 15 Apr 2026 18:54:18 -0400 Subject: [PATCH 2/8] fix(patch): on non-UTF8 filename error rather than panic This is unfortunate we doesn't support non-UTF8 filename with `Patch<'_, str>` type (pre-existing btw). --- src/patch/error.rs | 4 ++++ src/patch/parse.rs | 26 +++++++++++++++++--------- src/patch/tests.rs | 15 +++++++-------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/patch/error.rs b/src/patch/error.rs index f55f4969..eecf8767 100644 --- a/src/patch/error.rs +++ b/src/patch/error.rs @@ -110,6 +110,9 @@ pub(crate) enum ParsePatchErrorKind { /// Orphaned hunk header found after trailing content. OrphanedHunkHeader, + + /// Filename contains invalid UTF-8 when parsing as text. + InvalidUtf8Path, } impl fmt::Display for ParsePatchErrorKind { @@ -136,6 +139,7 @@ impl fmt::Display for ParsePatchErrorKind { Self::UnexpectedHunkLine => "unexpected line in hunk body", Self::MissingNewline => "missing newline", Self::OrphanedHunkHeader => "orphaned hunk header after trailing content", + Self::InvalidUtf8Path => "filename is not valid UTF-8", }; write!(f, "{msg}") } diff --git a/src/patch/parse.rs b/src/patch/parse.rs index dd74b15c..38bdaa40 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -74,12 +74,16 @@ pub(crate) fn parse_one(input: &str) -> (Result>, usize) { Err(e) => return (Err(e), parser.offset()), }; - let patch = Patch::new( - header.0.map(convert_cow_to_str), - header.1.map(convert_cow_to_str), - hunks, - ); - (Ok(patch), parser.offset()) + let original = match header.0.map(convert_cow_to_str).transpose() { + Ok(o) => o, + Err(e) => return (Err(e), parser.offset()), + }; + let modified = match header.1.map(convert_cow_to_str).transpose() { + Ok(m) => m, + Err(e) => return (Err(e), parser.offset()), + }; + + (Ok(Patch::new(original, modified, hunks)), parser.offset()) } pub fn parse_strict(input: &str) -> Result> { @@ -113,10 +117,14 @@ pub fn parse_bytes_strict(input: &[u8]) -> Result> { } // This is only used when the type originated as a utf8 string -fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Cow<'_, str> { +fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Result> { match cow { - Cow::Borrowed(b) => std::str::from_utf8(b).unwrap().into(), - Cow::Owned(o) => String::from_utf8(o).unwrap().into(), + Cow::Borrowed(b) => std::str::from_utf8(b) + .map(Cow::Borrowed) + .map_err(|_| ParsePatchErrorKind::InvalidUtf8Path.into()), + Cow::Owned(o) => String::from_utf8(o) + .map(Cow::Owned) + .map_err(|_| ParsePatchErrorKind::InvalidUtf8Path.into()), } } diff --git a/src/patch/tests.rs b/src/patch/tests.rs index ded7a7a1..362dc373 100644 --- a/src/patch/tests.rs +++ b/src/patch/tests.rs @@ -619,14 +619,10 @@ fn plain_filename_roundtrip() { } // Octal escape \377 decodes to 0xFF, which is not valid UTF-8. -// When parsing into `Patch<'_, str>`, `convert_cow_to_str` panics -// via `unwrap()` instead of returning a parse error. -// -// This documents the pre-existing bug that the reviewer flagged: -// https://github.com/bmwill/diffy/pull/59#r3089024322 +// When parsing into `Patch<'_, str>`, this returns a parse error +// instead of panicking. #[test] -#[should_panic] -fn non_utf8_escaped_filename_panics_on_str_parse() { +fn non_utf8_escaped_filename_returns_error_on_str_parse() { let s = r#"\ --- "a/foo\377" +++ "b/foo\377" @@ -634,7 +630,10 @@ fn non_utf8_escaped_filename_panics_on_str_parse() { -x +y "#; - let _ = parse(s); + assert_eq!( + parse(s).unwrap_err().kind, + ParsePatchErrorKind::InvalidUtf8Path, + ); } mod error_display { From 7f0652022980236dec6bee779f2e33ade1c72976 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Sun, 12 Apr 2026 10:58:01 -0400 Subject: [PATCH 3/8] refactor(patch): ParseOpts for configurable parsing behavior --- src/patch/parse.rs | 46 +++++++++++++++++++++++++++++++----------- src/patch_set/parse.rs | 3 ++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index 38bdaa40..357c05fb 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -12,6 +12,31 @@ use std::borrow::Cow; type Result = std::result::Result; +/// Options that control parsing behavior. +/// +/// Defaults match the [`parse`]/[`parse_bytes`] behavior. +#[derive(Clone, Copy)] +pub(crate) struct ParseOpts { + reject_orphaned_hunks: bool, +} + +impl Default for ParseOpts { + fn default() -> Self { + Self { + reject_orphaned_hunks: false, + } + } +} + +impl ParseOpts { + /// Reject orphaned `@@ ` hunk headers after parsed hunks, + /// matching `git apply` behavior. + pub(crate) fn reject_orphaned_hunks(mut self) -> Self { + self.reject_orphaned_hunks = true; + self + } +} + struct Parser<'a, T: Text + ?Sized> { lines: std::iter::Peekable>, offset: usize, @@ -54,7 +79,7 @@ impl<'a, T: Text + ?Sized> Parser<'a, T> { } pub fn parse(input: &str) -> Result> { - let (result, _consumed) = parse_one(input); + let (result, _consumed) = parse_one(input, ParseOpts::default()); result } @@ -62,7 +87,7 @@ pub fn parse(input: &str) -> Result> { /// /// Always returns consumed bytes alongside the result /// so callers can advance past the parsed or partially parsed content. -pub(crate) fn parse_one(input: &str) -> (Result>, usize) { +pub(crate) fn parse_one(input: &str, opts: ParseOpts) -> (Result>, usize) { let mut parser = Parser::new(input); let header = match patch_header(&mut parser) { @@ -73,6 +98,11 @@ pub(crate) fn parse_one(input: &str) -> (Result>, usize) { Ok(h) => h, Err(e) => return (Err(e), parser.offset()), }; + if opts.reject_orphaned_hunks { + if let Err(e) = reject_orphaned_hunk_headers(&mut parser) { + return (Err(e), parser.offset()); + } + } let original = match header.0.map(convert_cow_to_str).transpose() { Ok(o) => o, @@ -87,16 +117,8 @@ pub(crate) fn parse_one(input: &str) -> (Result>, usize) { } pub fn parse_strict(input: &str) -> Result> { - let mut parser = Parser::new(input); - let header = patch_header(&mut parser)?; - let hunks = hunks(&mut parser)?; - reject_orphaned_hunk_headers(&mut parser)?; - - Ok(Patch::new( - header.0.map(convert_cow_to_str), - header.1.map(convert_cow_to_str), - hunks, - )) + let (result, _consumed) = parse_one(input, ParseOpts::default().reject_orphaned_hunks()); + result } pub fn parse_bytes(input: &[u8]) -> Result> { diff --git a/src/patch_set/parse.rs b/src/patch_set/parse.rs index c4864f95..cec612b2 100644 --- a/src/patch_set/parse.rs +++ b/src/patch_set/parse.rs @@ -83,7 +83,8 @@ impl<'a> PatchSet<'a> { let patch_input = &remaining[patch_start..]; - let (result, consumed) = parse_one(patch_input); + let opts = crate::patch::parse::ParseOpts::default(); + let (result, consumed) = parse_one(patch_input, opts); // Always advance so the iterator makes progress even on error. let abs_patch_start = self.offset + patch_start; self.offset += patch_start + consumed; From 1adbb189148de15673e1f5eac7d90bcefb495c4c Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Sun, 12 Apr 2026 11:05:06 -0400 Subject: [PATCH 4/8] refactor(patch): make preamble skipping configurable --- src/patch/parse.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index 357c05fb..c2ed5f09 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -17,18 +17,30 @@ type Result = std::result::Result; /// Defaults match the [`parse`]/[`parse_bytes`] behavior. #[derive(Clone, Copy)] pub(crate) struct ParseOpts { + skip_preamble: bool, reject_orphaned_hunks: bool, } impl Default for ParseOpts { fn default() -> Self { Self { + skip_preamble: true, reject_orphaned_hunks: false, } } } impl ParseOpts { + /// Don't skip preamble lines before `---`/`+++`/`@@`. + /// + /// Useful when the caller has already positioned the input + /// at the start of the patch content. + #[allow(dead_code)] // will be used by patch_set parser + pub(crate) fn no_skip_preamble(mut self) -> Self { + self.skip_preamble = false; + self + } + /// Reject orphaned `@@ ` hunk headers after parsed hunks, /// matching `git apply` behavior. pub(crate) fn reject_orphaned_hunks(mut self) -> Self { @@ -90,7 +102,7 @@ pub fn parse(input: &str) -> Result> { pub(crate) fn parse_one(input: &str, opts: ParseOpts) -> (Result>, usize) { let mut parser = Parser::new(input); - let header = match patch_header(&mut parser) { + let header = match patch_header(&mut parser, &opts) { Ok(h) => h, Err(e) => return (Err(e), parser.offset()), }; @@ -123,7 +135,7 @@ pub fn parse_strict(input: &str) -> Result> { pub fn parse_bytes(input: &[u8]) -> Result> { let mut parser = Parser::new(input); - let header = patch_header(&mut parser)?; + let header = patch_header(&mut parser, &ParseOpts::default())?; let hunks = hunks(&mut parser)?; Ok(Patch::new(header.0, header.1, hunks)) @@ -131,7 +143,7 @@ pub fn parse_bytes(input: &[u8]) -> Result> { pub fn parse_bytes_strict(input: &[u8]) -> Result> { let mut parser = Parser::new(input); - let header = patch_header(&mut parser)?; + let header = patch_header(&mut parser, &ParseOpts::default())?; let hunks = hunks(&mut parser)?; reject_orphaned_hunk_headers(&mut parser)?; @@ -153,8 +165,11 @@ fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Result> { #[allow(clippy::type_complexity)] fn patch_header<'a, T: Text + ToOwned + ?Sized>( parser: &mut Parser<'a, T>, + opts: &ParseOpts, ) -> Result<(Option>, Option>)> { - skip_header_preamble(parser)?; + if opts.skip_preamble { + skip_header_preamble(parser)?; + } let mut filename1 = None; let mut filename2 = None; From 897983ab438c5898eda423af73babdf07725b539 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Sun, 12 Apr 2026 11:09:12 -0400 Subject: [PATCH 5/8] refactor(patch): organize item order --- src/patch/parse.rs | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index c2ed5f09..32219c9f 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -95,6 +95,28 @@ pub fn parse(input: &str) -> Result> { result } +pub fn parse_strict(input: &str) -> Result> { + let (result, _consumed) = parse_one(input, ParseOpts::default().reject_orphaned_hunks()); + result +} + +pub fn parse_bytes(input: &[u8]) -> Result> { + let mut parser = Parser::new(input); + let header = patch_header(&mut parser, &ParseOpts::default())?; + let hunks = hunks(&mut parser)?; + + Ok(Patch::new(header.0, header.1, hunks)) +} + +pub fn parse_bytes_strict(input: &[u8]) -> Result> { + let mut parser = Parser::new(input); + let header = patch_header(&mut parser, &ParseOpts::default())?; + let hunks = hunks(&mut parser)?; + reject_orphaned_hunk_headers(&mut parser)?; + + Ok(Patch::new(header.0, header.1, hunks)) +} + /// Parses one patch from input. /// /// Always returns consumed bytes alongside the result @@ -128,28 +150,6 @@ pub(crate) fn parse_one(input: &str, opts: ParseOpts) -> (Result> (Ok(Patch::new(original, modified, hunks)), parser.offset()) } -pub fn parse_strict(input: &str) -> Result> { - let (result, _consumed) = parse_one(input, ParseOpts::default().reject_orphaned_hunks()); - result -} - -pub fn parse_bytes(input: &[u8]) -> Result> { - let mut parser = Parser::new(input); - let header = patch_header(&mut parser, &ParseOpts::default())?; - let hunks = hunks(&mut parser)?; - - Ok(Patch::new(header.0, header.1, hunks)) -} - -pub fn parse_bytes_strict(input: &[u8]) -> Result> { - let mut parser = Parser::new(input); - let header = patch_header(&mut parser, &ParseOpts::default())?; - let hunks = hunks(&mut parser)?; - reject_orphaned_hunk_headers(&mut parser)?; - - Ok(Patch::new(header.0, header.1, hunks)) -} - // This is only used when the type originated as a utf8 string fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Result> { match cow { From 9e117d2c363ccfa82f1a964439fb58af1f648a65 Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Sun, 12 Apr 2026 11:10:47 -0400 Subject: [PATCH 6/8] refactor(patch): wire up ParseOpts for byte parsing --- src/patch/parse.rs | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index 32219c9f..b97e12fc 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -101,20 +101,13 @@ pub fn parse_strict(input: &str) -> Result> { } pub fn parse_bytes(input: &[u8]) -> Result> { - let mut parser = Parser::new(input); - let header = patch_header(&mut parser, &ParseOpts::default())?; - let hunks = hunks(&mut parser)?; - - Ok(Patch::new(header.0, header.1, hunks)) + let (result, _consumed) = parse_bytes_one(input, ParseOpts::default()); + result } pub fn parse_bytes_strict(input: &[u8]) -> Result> { - let mut parser = Parser::new(input); - let header = patch_header(&mut parser, &ParseOpts::default())?; - let hunks = hunks(&mut parser)?; - reject_orphaned_hunk_headers(&mut parser)?; - - Ok(Patch::new(header.0, header.1, hunks)) + let (result, _consumed) = parse_bytes_one(input, ParseOpts::default().reject_orphaned_hunks()); + result } /// Parses one patch from input. @@ -150,6 +143,27 @@ pub(crate) fn parse_one(input: &str, opts: ParseOpts) -> (Result> (Ok(Patch::new(original, modified, hunks)), parser.offset()) } +/// Like [`parse_one`] but for bytes. +pub(crate) fn parse_bytes_one(input: &[u8], opts: ParseOpts) -> (Result>, usize) { + let mut parser = Parser::new(input); + + let header = match patch_header(&mut parser, &opts) { + Ok(h) => h, + Err(e) => return (Err(e), parser.offset()), + }; + let hunks = match hunks(&mut parser) { + Ok(h) => h, + Err(e) => return (Err(e), parser.offset()), + }; + if opts.reject_orphaned_hunks { + if let Err(e) = reject_orphaned_hunk_headers(&mut parser) { + return (Err(e), parser.offset()); + } + } + + (Ok(Patch::new(header.0, header.1, hunks)), parser.offset()) +} + // This is only used when the type originated as a utf8 string fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Result> { match cow { From 34af0895da4a38f0f78fa6eb8df154f6a8e6306f Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Wed, 15 Apr 2026 18:20:08 -0400 Subject: [PATCH 7/8] refactor: TODO for eliminate patch parsing variants --- src/patch/parse.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index b97e12fc..913c1fae 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -90,6 +90,11 @@ impl<'a, T: Text + ?Sized> Parser<'a, T> { } } +// TODO: make a better API for lib consumers +// +// Too many different variants of `parse*` functions here. +// And that also propogate to `Patch::from_{str,bytes}{,_strict}`. + pub fn parse(input: &str) -> Result> { let (result, _consumed) = parse_one(input, ParseOpts::default()); result From a7aee0c518e630ae5610ff5029d2cd454d2ee4cf Mon Sep 17 00:00:00 2001 From: Weihang Lo Date: Wed, 15 Apr 2026 19:03:51 -0400 Subject: [PATCH 8/8] refactor(patch): `patch_header` generic over Text `Text::owned_from_bytes` conversion is needed when escape sequences are decoded. --- src/patch/parse.rs | 60 +++++++--------------------------------------- src/utils.rs | 38 ++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/src/patch/parse.rs b/src/patch/parse.rs index 913c1fae..3d1fb26c 100644 --- a/src/patch/parse.rs +++ b/src/patch/parse.rs @@ -106,12 +106,12 @@ pub fn parse_strict(input: &str) -> Result> { } pub fn parse_bytes(input: &[u8]) -> Result> { - let (result, _consumed) = parse_bytes_one(input, ParseOpts::default()); + let (result, _consumed) = parse_one(input, ParseOpts::default()); result } pub fn parse_bytes_strict(input: &[u8]) -> Result> { - let (result, _consumed) = parse_bytes_one(input, ParseOpts::default().reject_orphaned_hunks()); + let (result, _consumed) = parse_one(input, ParseOpts::default().reject_orphaned_hunks()); result } @@ -119,37 +119,10 @@ pub fn parse_bytes_strict(input: &[u8]) -> Result> { /// /// Always returns consumed bytes alongside the result /// so callers can advance past the parsed or partially parsed content. -pub(crate) fn parse_one(input: &str, opts: ParseOpts) -> (Result>, usize) { - let mut parser = Parser::new(input); - - let header = match patch_header(&mut parser, &opts) { - Ok(h) => h, - Err(e) => return (Err(e), parser.offset()), - }; - let hunks = match hunks(&mut parser) { - Ok(h) => h, - Err(e) => return (Err(e), parser.offset()), - }; - if opts.reject_orphaned_hunks { - if let Err(e) = reject_orphaned_hunk_headers(&mut parser) { - return (Err(e), parser.offset()); - } - } - - let original = match header.0.map(convert_cow_to_str).transpose() { - Ok(o) => o, - Err(e) => return (Err(e), parser.offset()), - }; - let modified = match header.1.map(convert_cow_to_str).transpose() { - Ok(m) => m, - Err(e) => return (Err(e), parser.offset()), - }; - - (Ok(Patch::new(original, modified, hunks)), parser.offset()) -} - -/// Like [`parse_one`] but for bytes. -pub(crate) fn parse_bytes_one(input: &[u8], opts: ParseOpts) -> (Result>, usize) { +pub(crate) fn parse_one( + input: &T, + opts: ParseOpts, +) -> (Result>, usize) { let mut parser = Parser::new(input); let header = match patch_header(&mut parser, &opts) { @@ -169,23 +142,11 @@ pub(crate) fn parse_bytes_one(input: &[u8], opts: ParseOpts) -> (Result) -> Result> { - match cow { - Cow::Borrowed(b) => std::str::from_utf8(b) - .map(Cow::Borrowed) - .map_err(|_| ParsePatchErrorKind::InvalidUtf8Path.into()), - Cow::Owned(o) => String::from_utf8(o) - .map(Cow::Owned) - .map_err(|_| ParsePatchErrorKind::InvalidUtf8Path.into()), - } -} - #[allow(clippy::type_complexity)] -fn patch_header<'a, T: Text + ToOwned + ?Sized>( +fn patch_header<'a, T: Text + ?Sized>( parser: &mut Parser<'a, T>, opts: &ParseOpts, -) -> Result<(Option>, Option>)> { +) -> Result<(Option>, Option>)> { if opts.skip_preamble { skip_header_preamble(parser)?; } @@ -225,10 +186,7 @@ fn skip_header_preamble(parser: &mut Parser<'_, T>) -> Result< Ok(()) } -fn parse_filename<'a, T: Text + ToOwned + ?Sized>( - prefix: &str, - line: &'a T, -) -> Result> { +fn parse_filename<'a, T: Text + ?Sized>(prefix: &str, line: &'a T) -> Result> { let line = line .strip_prefix(prefix) .ok_or(ParsePatchErrorKind::InvalidFilename)?; diff --git a/src/utils.rs b/src/utils.rs index c3b15944..c4914566 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -132,7 +132,7 @@ impl<'a, T: Text + ?Sized> Iterator for LineIter<'a, T> { /// A helper trait for processing text like `str` and `[u8]` /// Useful for abstracting over those types for parsing as well as breaking input into lines -pub trait Text: Eq + Hash { +pub trait Text: Eq + Hash + ToOwned { fn is_empty(&self) -> bool; fn len(&self) -> usize; fn starts_with(&self, prefix: &str) -> bool; @@ -148,6 +148,12 @@ pub trait Text: Eq + Hash { #[allow(unused)] fn lines(&self) -> LineIter<'_, Self>; + /// Converts raw bytes into `Self::Owned`. + /// + /// Returns `None` if the bytes are not valid for this type + /// (e.g. non-UTF-8 bytes for `str`). + fn owned_from_bytes(bytes: Vec) -> Option; + fn parse(&self) -> Option { self.as_str().and_then(|s| s.parse().ok()) } @@ -158,6 +164,10 @@ impl Text for str { self.is_empty() } + fn owned_from_bytes(bytes: Vec) -> Option { + String::from_utf8(bytes).ok() + } + fn len(&self) -> usize { self.len() } @@ -209,6 +219,10 @@ impl Text for [u8] { self.is_empty() } + fn owned_from_bytes(bytes: Vec) -> Option> { + Some(bytes) + } + fn len(&self) -> usize { self.len() } @@ -292,27 +306,29 @@ fn find_byte(haystack: &[u8], byte: u8) -> Option { /// /// See [`byte_needs_quoting`] for the set of characters that /// require quoting. -pub(crate) fn escaped_filename( +pub(crate) fn escaped_filename( filename: &T, -) -> Result, ParsePatchError> { +) -> Result, ParsePatchError> { if let Some(inner) = filename .strip_prefix("\"") .and_then(|s| s.strip_suffix("\"")) { - decode_escaped(inner) + match decode_escaped(inner.as_bytes())? { + None => Ok(Cow::Borrowed(inner)), + Some(bytes) => T::owned_from_bytes(bytes) + .map(Cow::Owned) + .ok_or_else(|| ParsePatchErrorKind::InvalidUtf8Path.into()), + } } else { let bytes = filename.as_bytes(); if bytes.iter().any(|b| byte_needs_quoting(*b)) { return Err(ParsePatchErrorKind::InvalidCharInUnquotedFilename.into()); } - Ok(bytes.into()) + Ok(Cow::Borrowed(filename)) } } -fn decode_escaped( - escaped: &T, -) -> Result, ParsePatchError> { - let bytes = escaped.as_bytes(); +fn decode_escaped(bytes: &[u8]) -> Result>, ParsePatchError> { let mut result = Vec::new(); let mut i = 0; let mut last_copy = 0; @@ -365,8 +381,8 @@ fn decode_escaped( if needs_allocation { result.extend_from_slice(&bytes[last_copy..]); - Ok(Cow::Owned(result)) + Ok(Some(result)) } else { - Ok(Cow::Borrowed(bytes)) + Ok(None) } }