diff --git a/gix-attributes/src/parse.rs b/gix-attributes/src/parse.rs index a5df8eda2ff..a14dc5f5604 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,16 @@ 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) + 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/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 121ba27e820..b710a0b22ab 100644 --- a/gix-odb/src/alternate/parse.rs +++ b/gix-odb/src/alternate/parse.rs @@ -1,33 +1,51 @@ -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)] 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> { - 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"#") { - continue; - } - out.push( - gix_path::try_from_bstr(if line.starts_with(b"\"") { - gix_quote::ansi_c::undo(line)?.0 +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 { - Cow::Borrowed(line) - }) - .map_err(|_| Error::PathConversion(line.to_vec()))? - .into_owned(), - ); + // 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) } - 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, diff --git a/gix-quote/src/ansi_c.rs b/gix-quote/src/ansi_c.rs index b9a725646bc..bf2998c0b2d 100644 --- a/gix-quote/src/ansi_c.rs +++ b/gix-quote/src/ansi_c.rs @@ -15,6 +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. /// See [the tests][tests] for quotation examples. /// /// [tests]: https://github.com/GitoxideLabs/gitoxide/blob/64872690e60efdd9267d517f4d9971eecd3b875c/gix-quote/tests/quote.rs#L57-L74 @@ -93,9 +94,9 @@ pub fn undo(input: &BStr) -> Result<(Cow<'_, BStr>, usize), undo::Error> { } } None => { - out.extend_from_slice(input); - consumed += input.len(); - break; + 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..847dda7900f 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" + ); + } + } + #[test] fn fuzzed() { for invalid in ["\"\\", "\"Q\u{2}QT\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\0\0\\"] {