From 2231e4bafbf2e04b2a4de6e409aee3c4af1f04ad Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Thu, 6 Aug 2026 19:36:39 +0530 Subject: [PATCH 1/5] change!: a quote that is never closed is now an error. `undo()` returned the input unaltered once it ran off the end while looking for the closing quote, so `"abc` unquoted to `abc` and reported all 4 bytes as consumed. Git's `unquote_c_style()` has no such branch and fails instead. A lone `"` already errored, so the two cases disagreed with each other. The arm was written before `undo()` reported consumed bytes; a052d7967 added that feature by extending it with `consumed += input.len()`, treating the unterminated remainder as consumed rather than asking whether reaching the end should have succeeded at all. --- gix-quote/src/ansi_c.rs | 12 +++++++++--- gix-quote/tests/quote.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/gix-quote/src/ansi_c.rs b/gix-quote/src/ansi_c.rs index b9a725646bc..79729125764 100644 --- a/gix-quote/src/ansi_c.rs +++ b/gix-quote/src/ansi_c.rs @@ -15,6 +15,10 @@ use gix_error::{ErrorExt, OptionExt, ResultExt, ValidationError}; /// quotation, otherwise a new unquoted string will always be allocated. /// The amount of consumed bytes allow to pass strings that start with a quote, and skip all quoted text for additional processing /// +/// A quote that is never closed is an error, just as in Git's `unquote_c_style()`. Note that Git's +/// callers differ in how they respond to that: some abort, while those reading attributes and +/// alternates fall back to using the raw, still-quoted text. +/// /// See [the tests][tests] for quotation examples. /// /// [tests]: https://github.com/GitoxideLabs/gitoxide/blob/64872690e60efdd9267d517f4d9971eecd3b875c/gix-quote/tests/quote.rs#L57-L74 @@ -93,9 +97,11 @@ pub fn undo(input: &BStr) -> Result<(Cow<'_, BStr>, usize), undo::Error> { } } None => { - out.extend_from_slice(input); - consumed += input.len(); - break; + // Running out of input before the closing quote is an error in Git's + // `unquote_c_style()` as well. + return Err( + ValidationError::new_with_input("Missing closing quote in quoted string", original).raise(), + ); } } } diff --git a/gix-quote/tests/quote.rs b/gix-quote/tests/quote.rs index d94ba9398cd..233dace7f70 100644 --- a/gix-quote/tests/quote.rs +++ b/gix-quote/tests/quote.rs @@ -81,6 +81,16 @@ mod ansi_c { assert_eq!(&input[consumed..], " out of quote"); } + #[test] + fn a_quote_that_is_never_closed_is_an_error() { + for unterminated in [r#"""#, r#""abc"#, r#""abc def"#, r#""abc\"#, r#""\""#] { + assert!( + ansi_c::undo(unterminated.into()).is_err(), + "{unterminated:?} should not parse, just like in `unquote_c_style()`" + ); + } + } + #[test] fn fuzzed() { for invalid in ["\"\\", "\"Q\u{2}QT\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\0\0\\"] { From 253edde08c381ac7b7fe0d9ebc912a132c858d69 Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Thu, 6 Aug 2026 19:36:39 +0530 Subject: [PATCH 2/5] change!: fall back to the raw text when unquoting fails. Both callers of `gix_quote::ansi_c::undo()` turned a failure into an error of their own, while Git keeps the raw, still-quoted token as the value: * `parse_attr_line()` in `attr.c` falls through to its unquoted branch * Git's alternates parsing in `odb.c` spells the unterminated case out in a comment of its own For attributes this also covers invalid escapes, which were previously a hard error for the whole line. Git keeps `"\!x"` as the pattern, where the backslash goes on to escape the `!` for the matcher rather than negating the pattern. Both fall back before checking for the `[attr]` macro prefix, as Git does, so a line like `"[attr]x` stays a pattern on either side. The `Unquote` variants are unreachable now and have been removed. --- gix-attributes/src/parse.rs | 22 +++++++++++----------- gix-attributes/tests/attributes/parse.rs | 20 +++++++++++++++++--- gix-odb/src/alternate/parse.rs | 11 +++++------ gix-odb/tests/odb/alternate.rs | 20 ++++++++++++++++++++ 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/gix-attributes/src/parse.rs b/gix-attributes/src/parse.rs index a5df8eda2ff..4ec3bed85d2 100644 --- a/gix-attributes/src/parse.rs +++ b/gix-attributes/src/parse.rs @@ -26,8 +26,6 @@ mod error { AttributeName { line_number: usize, attribute: BString }, #[error("Macro in line {line_number} has an invalid name: {macro_name}")] MacroName { line_number: usize, macro_name: BString }, - #[error("Could not unquote attributes line")] - Unquote(#[from] gix_quote::ansi_c::undo::Error), } } pub use error::Error; @@ -122,16 +120,18 @@ fn parse_line(line: &BStr, line_number: usize) -> Option, return None; } - let (line, attrs): (Cow<'_, _>, _) = if line.starts_with(b"\"") { - let (unquoted, consumed) = match gix_quote::ansi_c::undo(line) { - Ok(res) => res, - Err(err) => return Some(Err(err.into())), - }; - (unquoted, &line[consumed..]) - } else { - line.find_byteset(BLANKS) + // Broken quoting, like a pattern that never closes its quote, falls back to the raw text - + // `parse_attr_line()` in Git's `attr.c` reaches its unquoted branch the same way. + let unquoted = line + .starts_with(b"\"") + .then(|| gix_quote::ansi_c::undo(line).ok()) + .flatten(); + let (line, attrs): (Cow<'_, _>, _) = match unquoted { + Some((unquoted, consumed)) => (unquoted, &line[consumed..]), + None => line + .find_byteset(BLANKS) .map(|pos| (line[..pos].as_bstr().into(), line[pos..].as_bstr())) - .unwrap_or((line.into(), [].as_bstr())) + .unwrap_or((line.into(), [].as_bstr())), }; let kind_res = match line.strip_prefix(b"[attr]").filter(|name| !name.is_empty()) { diff --git a/gix-attributes/tests/attributes/parse.rs b/gix-attributes/tests/attributes/parse.rs index f68690bfe6b..eb3717dcdf7 100644 --- a/gix-attributes/tests/attributes/parse.rs +++ b/gix-attributes/tests/attributes/parse.rs @@ -115,9 +115,23 @@ fn exclamation_marks_must_be_escaped_or_error_unlike_gitignore() { } #[test] -fn invalid_escapes_in_quotes_are_an_error() { - assert!(matches!(try_line(r#""\!hello""#), Err(parse::Error::Unquote(_)))); - assert!(lenient_lines(r#""\!hello""#).is_empty()); +fn broken_quoting_falls_back_to_the_raw_text() { + assert_eq!( + line(r#""\!hello""#), + (pattern(r#""\!hello""#, Mode::NO_SUB_DIR, Some(1)), vec![], 1), + "an invalid escape leaves the quotes in place, so the leading `!` no longer negates, \ + and the backslash goes on to escape it for the matcher" + ); + assert_eq!( + line(r#""abc"#), + (pattern(r#""abc"#, Mode::NO_SUB_DIR, None), vec![], 1), + "so does a quote that is never closed" + ); + assert_eq!( + line(r#""abc def"#), + (pattern(r#""abc"#, Mode::NO_SUB_DIR, None), vec![set("def")], 1), + "the raw text is then split on blanks like any unquoted pattern" + ); } #[test] diff --git a/gix-odb/src/alternate/parse.rs b/gix-odb/src/alternate/parse.rs index 121ba27e820..aca36ea45d6 100644 --- a/gix-odb/src/alternate/parse.rs +++ b/gix-odb/src/alternate/parse.rs @@ -8,8 +8,6 @@ use gix_object::bstr::ByteSlice; pub enum Error { #[error("Could not obtain an object path for the alternate directory '{}'", String::from_utf8_lossy(.0))] PathConversion(Vec), - #[error("Could not unquote alternate path")] - Unquote(#[from] gix_quote::ansi_c::undo::Error), } pub(crate) fn content(input: &[u8]) -> Result, Error> { @@ -20,10 +18,11 @@ pub(crate) fn content(input: &[u8]) -> Result, Error> { continue; } out.push( - gix_path::try_from_bstr(if line.starts_with(b"\"") { - gix_quote::ansi_c::undo(line)?.0 - } else { - Cow::Borrowed(line) + // Broken quoting, like an entry that doesn't end with a quote, falls back to the raw + // line - a case that Git's alternates parsing in `odb.c` calls out in its own comment. + gix_path::try_from_bstr(match line.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(line)) { + Some(Ok((unquoted, _consumed))) => unquoted, + _ => Cow::Borrowed(line), }) .map_err(|_| Error::PathConversion(line.to_vec()))? .into_owned(), diff --git a/gix-odb/tests/odb/alternate.rs b/gix-odb/tests/odb/alternate.rs index 22d58a0c6a4..00daac81b58 100644 --- a/gix-odb/tests/odb/alternate.rs +++ b/gix-odb/tests/odb/alternate.rs @@ -148,6 +148,26 @@ fn single_link_with_comment_before_path_and_ansi_c_escape() -> crate::Result { Ok(()) } +#[test] +fn a_quote_that_is_never_closed_is_used_as_a_literal_path() -> crate::Result { + let tmp = gix_testtools::tempfile::TempDir::new()?; + // The entry is relative so that it can start with the quote that is never closed. + let (from, to) = alternate_with_content( + tmp.path().join("a"), + tmp.path().join("a").join("\"unterminated"), + br#""unterminated"#.to_vec(), + None, + )?; + + let alternates = alternate::resolve(from, &std::env::current_dir()?)?; + assert_eq!( + alternates, + vec![to], + "broken quoting falls back to the raw line, like Git's alternates parsing does" + ); + Ok(()) +} + #[test] fn no_alternate_in_first_objects_dir() -> crate::Result { let tmp = gix_testtools::tempfile::TempDir::new()?; From b7a71ab68b8256ccc64cc1425a9b5e67eef478be Mon Sep 17 00:00:00 2001 From: Amey Pawar Date: Thu, 6 Aug 2026 20:01:48 +0530 Subject: [PATCH 3/5] test: exercise alternates parsing without touching the filesystem. The previous test reached `content()` through `alternate::resolve()`, which meant creating a directory whose name begins with the quote that is never closed. That is not a legal filename on Windows, where it failed with `InvalidFilename`. Calling `content()` directly tests the same parsing without a filesystem, and adds a companion case showing that intact quoting still decodes its escapes. --- gix-odb/src/alternate/parse.rs | 24 ++++++++++++++++++++++++ gix-odb/tests/odb/alternate.rs | 20 -------------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/gix-odb/src/alternate/parse.rs b/gix-odb/src/alternate/parse.rs index aca36ea45d6..84d8edcb9a5 100644 --- a/gix-odb/src/alternate/parse.rs +++ b/gix-odb/src/alternate/parse.rs @@ -30,3 +30,27 @@ pub(crate) fn content(input: &[u8]) -> Result, Error> { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::content; + use std::path::PathBuf; + + #[test] + fn a_quote_that_is_never_closed_is_used_as_a_literal_path() { + assert_eq!( + content(br#""unterminated"#).expect("no path conversion issue"), + vec![PathBuf::from(r#""unterminated"#)], + "broken quoting falls back to the raw line, like Git's alternates parsing does" + ); + } + + #[test] + fn a_properly_quoted_path_is_unquoted() { + assert_eq!( + content(br#""quoted\tpath""#).expect("no path conversion issue"), + vec![PathBuf::from("quoted\tpath")], + "…while quoting that is intact still decodes its escapes" + ); + } +} diff --git a/gix-odb/tests/odb/alternate.rs b/gix-odb/tests/odb/alternate.rs index 00daac81b58..22d58a0c6a4 100644 --- a/gix-odb/tests/odb/alternate.rs +++ b/gix-odb/tests/odb/alternate.rs @@ -148,26 +148,6 @@ fn single_link_with_comment_before_path_and_ansi_c_escape() -> crate::Result { Ok(()) } -#[test] -fn a_quote_that_is_never_closed_is_used_as_a_literal_path() -> crate::Result { - let tmp = gix_testtools::tempfile::TempDir::new()?; - // The entry is relative so that it can start with the quote that is never closed. - let (from, to) = alternate_with_content( - tmp.path().join("a"), - tmp.path().join("a").join("\"unterminated"), - br#""unterminated"#.to_vec(), - None, - )?; - - let alternates = alternate::resolve(from, &std::env::current_dir()?)?; - assert_eq!( - alternates, - vec![to], - "broken quoting falls back to the raw line, like Git's alternates parsing does" - ); - Ok(()) -} - #[test] fn no_alternate_in_first_objects_dir() -> crate::Result { let tmp = gix_testtools::tempfile::TempDir::new()?; From 76f96435c5bcdf586f001a18c5c97d632261bffd Mon Sep 17 00:00:00 2001 From: Byron Date: Fri, 7 Aug 2026 13:11:27 +0200 Subject: [PATCH 4/5] review - Git attempts C-style unquoting before looking for the next separator, allowing a quoted alternate path to contain a literal newline. Parse the remaining input before consuming its separator, while retaining raw-text fallback for broken quoting. Add a regression test for the multiline case. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- gix-attributes/src/parse.rs | 2 -- gix-odb/src/alternate/parse.rs | 52 +++++++++++++++++++++++----------- gix-quote/src/ansi_c.rs | 7 +---- gix-quote/tests/quote.rs | 2 +- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/gix-attributes/src/parse.rs b/gix-attributes/src/parse.rs index 4ec3bed85d2..a14dc5f5604 100644 --- a/gix-attributes/src/parse.rs +++ b/gix-attributes/src/parse.rs @@ -120,8 +120,6 @@ fn parse_line(line: &BStr, line_number: usize) -> Option, return None; } - // Broken quoting, like a pattern that never closes its quote, falls back to the raw text - - // `parse_attr_line()` in Git's `attr.c` reaches its unquoted branch the same way. let unquoted = line .starts_with(b"\"") .then(|| gix_quote::ansi_c::undo(line).ok()) diff --git a/gix-odb/src/alternate/parse.rs b/gix-odb/src/alternate/parse.rs index 84d8edcb9a5..1f3b8953c94 100644 --- a/gix-odb/src/alternate/parse.rs +++ b/gix-odb/src/alternate/parse.rs @@ -10,22 +10,34 @@ pub enum Error { PathConversion(Vec), } -pub(crate) fn content(input: &[u8]) -> Result, Error> { +pub(crate) fn content(mut input: &[u8]) -> Result, Error> { let mut out = Vec::new(); - for line in input.split(|b| *b == b'\n') { - let line = line.as_bstr(); - if line.is_empty() || line.starts_with(b"#") { + while !input.is_empty() { + let entry = input.as_bstr(); + let end_of_line = || entry.find_byte(b'\n').unwrap_or(entry.len()); + let (path, consumed) = if entry.starts_with(b"#") { + (None, end_of_line()) + } else { + // Like Git, try unquoting before treating a newline as the next separator. + match entry.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(entry)) { + Some(Ok((unquoted, consumed))) => (Some(unquoted), consumed), + _ => { + let consumed = end_of_line(); + (Some(Cow::Borrowed(entry[..consumed].as_bstr())), consumed) + } + } + }; + let original = &entry[..consumed]; + let maybe_nl = usize::from(consumed < input.len()); + input = &input[consumed + maybe_nl..]; + + let Some(path) = path.filter(|path| !path.is_empty()) else { continue; - } + }; out.push( - // Broken quoting, like an entry that doesn't end with a quote, falls back to the raw - // line - a case that Git's alternates parsing in `odb.c` calls out in its own comment. - gix_path::try_from_bstr(match line.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(line)) { - Some(Ok((unquoted, _consumed))) => unquoted, - _ => Cow::Borrowed(line), - }) - .map_err(|_| Error::PathConversion(line.to_vec()))? - .into_owned(), + gix_path::try_from_bstr(path) + .map_err(|_| Error::PathConversion(original.to_vec()))? + .into_owned(), ); } Ok(out) @@ -41,7 +53,7 @@ mod tests { assert_eq!( content(br#""unterminated"#).expect("no path conversion issue"), vec![PathBuf::from(r#""unterminated"#)], - "broken quoting falls back to the raw line, like Git's alternates parsing does" + "broken quoting falls back to the raw line" ); } @@ -49,8 +61,16 @@ mod tests { fn a_properly_quoted_path_is_unquoted() { assert_eq!( content(br#""quoted\tpath""#).expect("no path conversion issue"), - vec![PathBuf::from("quoted\tpath")], - "…while quoting that is intact still decodes its escapes" + vec![PathBuf::from("quoted\tpath")] + ); + } + + #[test] + fn a_quoted_path_may_contain_the_line_separator() { + assert_eq!( + content(b"\"quoted\npath\"\nnext").expect("no path conversion issue"), + vec![PathBuf::from("quoted\npath"), PathBuf::from("next")], + "Git looks for a closing quote before treating a newline as the next separator" ); } } diff --git a/gix-quote/src/ansi_c.rs b/gix-quote/src/ansi_c.rs index 79729125764..bf2998c0b2d 100644 --- a/gix-quote/src/ansi_c.rs +++ b/gix-quote/src/ansi_c.rs @@ -15,10 +15,7 @@ use gix_error::{ErrorExt, OptionExt, ResultExt, ValidationError}; /// quotation, otherwise a new unquoted string will always be allocated. /// The amount of consumed bytes allow to pass strings that start with a quote, and skip all quoted text for additional processing /// -/// A quote that is never closed is an error, just as in Git's `unquote_c_style()`. Note that Git's -/// callers differ in how they respond to that: some abort, while those reading attributes and -/// alternates fall back to using the raw, still-quoted text. -/// +/// A quote that is never closed is an error. /// See [the tests][tests] for quotation examples. /// /// [tests]: https://github.com/GitoxideLabs/gitoxide/blob/64872690e60efdd9267d517f4d9971eecd3b875c/gix-quote/tests/quote.rs#L57-L74 @@ -97,8 +94,6 @@ pub fn undo(input: &BStr) -> Result<(Cow<'_, BStr>, usize), undo::Error> { } } None => { - // Running out of input before the closing quote is an error in Git's - // `unquote_c_style()` as well. return Err( ValidationError::new_with_input("Missing closing quote in quoted string", original).raise(), ); diff --git a/gix-quote/tests/quote.rs b/gix-quote/tests/quote.rs index 233dace7f70..847dda7900f 100644 --- a/gix-quote/tests/quote.rs +++ b/gix-quote/tests/quote.rs @@ -86,7 +86,7 @@ mod ansi_c { for unterminated in [r#"""#, r#""abc"#, r#""abc def"#, r#""abc\"#, r#""\""#] { assert!( ansi_c::undo(unterminated.into()).is_err(), - "{unterminated:?} should not parse, just like in `unquote_c_style()`" + "{unterminated:?} should not parse" ); } } From ea1d0e28beb4f59db4e51aa8f759e51f1f93c5c9 Mon Sep 17 00:00:00 2001 From: Byron Date: Fri, 7 Aug 2026 13:27:28 +0200 Subject: [PATCH 5/5] feat: expose alternate-file parsing via `alternate::parse()` This is mostly for completeness and more low-level access. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- gix-odb/src/alternate/mod.rs | 3 +- gix-odb/src/alternate/parse.rs | 105 +++++++++++++-------------------- gix-odb/tests/odb/alternate.rs | 31 ++++++++++ 3 files changed, 73 insertions(+), 66 deletions(-) diff --git a/gix-odb/src/alternate/mod.rs b/gix-odb/src/alternate/mod.rs index 21daf0eb365..c19703115aa 100644 --- a/gix-odb/src/alternate/mod.rs +++ b/gix-odb/src/alternate/mod.rs @@ -22,6 +22,7 @@ use gix_path::realpath::MAX_SYMLINKS; /// pub mod parse; +pub use parse::function::parse; /// Returned by [`resolve()`] #[derive(thiserror::Error, Debug)] @@ -67,7 +68,7 @@ pub fn resolve(objects_directory: PathBuf, current_dir: &std::path::Path) -> Res seen.push((dir_canonicalized, parent_idx)); match fs::read(dir.join("info").join("alternates")) { Ok(input) => { - for path in parse::content(&input)?.into_iter().rev() { + for path in parse(&input)?.into_iter().rev() { dirs.push((Some(idx), objects_directory.join(path))); } } diff --git a/gix-odb/src/alternate/parse.rs b/gix-odb/src/alternate/parse.rs index 1f3b8953c94..b710a0b22ab 100644 --- a/gix-odb/src/alternate/parse.rs +++ b/gix-odb/src/alternate/parse.rs @@ -1,7 +1,3 @@ -use std::{borrow::Cow, path::PathBuf}; - -use gix_object::bstr::ByteSlice; - /// Returned as part of [`crate::alternate::Error::Parse`] #[derive(thiserror::Error, Debug)] #[expect(missing_docs)] @@ -10,67 +6,46 @@ pub enum Error { PathConversion(Vec), } -pub(crate) fn content(mut input: &[u8]) -> Result, Error> { - let mut out = Vec::new(); - while !input.is_empty() { - let entry = input.as_bstr(); - let end_of_line = || entry.find_byte(b'\n').unwrap_or(entry.len()); - let (path, consumed) = if entry.starts_with(b"#") { - (None, end_of_line()) - } else { - // Like Git, try unquoting before treating a newline as the next separator. - match entry.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(entry)) { - Some(Ok((unquoted, consumed))) => (Some(unquoted), consumed), - _ => { - let consumed = end_of_line(); - (Some(Cow::Borrowed(entry[..consumed].as_bstr())), consumed) +pub(super) mod function { + use super::Error; + use std::{borrow::Cow, path::PathBuf}; + + use gix_object::bstr::ByteSlice; + + /// Parse the raw contents of an `objects/info/alternates` file from `input` into paths. + /// + /// Empty entries and comments are ignored. Entries beginning with `"` use Git's C-style quoting, + /// which permits literal newlines in paths. Invalid quoting falls back to the raw entry. + pub fn parse(mut input: &[u8]) -> Result, Error> { + let mut out = Vec::new(); + while !input.is_empty() { + let entry = input.as_bstr(); + let end_of_line = || entry.find_byte(b'\n').unwrap_or(entry.len()); + let (path, consumed) = if entry.starts_with(b"#") { + (None, end_of_line()) + } else { + // Like Git, try unquoting before treating a newline as the next separator. + match entry.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(entry)) { + Some(Ok((unquoted, consumed))) => (Some(unquoted), consumed), + _ => { + let consumed = end_of_line(); + (Some(Cow::Borrowed(entry[..consumed].as_bstr())), consumed) + } } - } - }; - let original = &entry[..consumed]; - let maybe_nl = usize::from(consumed < input.len()); - input = &input[consumed + maybe_nl..]; - - let Some(path) = path.filter(|path| !path.is_empty()) else { - continue; - }; - out.push( - gix_path::try_from_bstr(path) - .map_err(|_| Error::PathConversion(original.to_vec()))? - .into_owned(), - ); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::content; - use std::path::PathBuf; - - #[test] - fn a_quote_that_is_never_closed_is_used_as_a_literal_path() { - assert_eq!( - content(br#""unterminated"#).expect("no path conversion issue"), - vec![PathBuf::from(r#""unterminated"#)], - "broken quoting falls back to the raw line" - ); - } - - #[test] - fn a_properly_quoted_path_is_unquoted() { - assert_eq!( - content(br#""quoted\tpath""#).expect("no path conversion issue"), - vec![PathBuf::from("quoted\tpath")] - ); - } - - #[test] - fn a_quoted_path_may_contain_the_line_separator() { - assert_eq!( - content(b"\"quoted\npath\"\nnext").expect("no path conversion issue"), - vec![PathBuf::from("quoted\npath"), PathBuf::from("next")], - "Git looks for a closing quote before treating a newline as the next separator" - ); + }; + let original = &entry[..consumed]; + let maybe_nl = usize::from(consumed < input.len()); + input = &input[consumed + maybe_nl..]; + + let Some(path) = path.filter(|path| !path.is_empty()) else { + continue; + }; + out.push( + gix_path::try_from_bstr(path) + .map_err(|_| Error::PathConversion(original.to_vec()))? + .into_owned(), + ); + } + Ok(out) } } diff --git a/gix-odb/tests/odb/alternate.rs b/gix-odb/tests/odb/alternate.rs index 22d58a0c6a4..26c89c98617 100644 --- a/gix-odb/tests/odb/alternate.rs +++ b/gix-odb/tests/odb/alternate.rs @@ -5,6 +5,37 @@ use std::{ use gix_odb::alternate; +mod parse { + use std::path::PathBuf; + + use gix_odb::alternate; + + #[test] + fn a_quote_that_is_never_closed_is_used_as_a_literal_path() { + assert_eq!( + alternate::parse(br#""unterminated"#).expect("no path conversion issue"), + vec![PathBuf::from(r#""unterminated"#)], + "broken quoting falls back to the raw line" + ); + } + + #[test] + fn a_properly_quoted_path_is_unquoted() { + assert_eq!( + alternate::parse(br#""quoted\tpath""#).expect("no path conversion issue"), + vec![PathBuf::from("quoted\tpath")] + ); + } + + #[test] + fn a_quoted_path_may_contain_the_line_separator() { + assert_eq!( + alternate::parse(b"\"quoted\npath\"\nnext").expect("no path conversion issue"), + vec![PathBuf::from("quoted\npath"), PathBuf::from("next")], + "Git looks for a closing quote before treating a newline as the next separator" + ); + } +} pub fn alternate( objects_at: impl Into, objects_to: impl Into,