Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions gix-attributes/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -122,16 +120,18 @@ fn parse_line(line: &BStr, line_number: usize) -> Option<Result<(Kind, Iter<'_>,
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()) {
Expand Down
20 changes: 17 additions & 3 deletions gix-attributes/tests/attributes/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
35 changes: 29 additions & 6 deletions gix-odb/src/alternate/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>),
#[error("Could not unquote alternate path")]
Unquote(#[from] gix_quote::ansi_c::undo::Error),
}

pub(crate) fn content(input: &[u8]) -> Result<Vec<PathBuf>, Error> {
Expand All @@ -20,14 +18,39 @@ pub(crate) fn content(input: &[u8]) -> Result<Vec<PathBuf>, 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(),
);
}
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"
);
}
}
12 changes: 9 additions & 3 deletions gix-quote/src/ansi_c.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
);
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions gix-quote/tests/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\\"] {
Expand Down
Loading