diff --git a/Cargo.lock b/Cargo.lock index 5c6038aa6..adb63c292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5531,6 +5531,7 @@ dependencies = [ "indoc", "insta", "itertools 0.14.0", + "memchr", "regex", "reqwest", "reqwest-middleware", diff --git a/crates/uv-requirements-txt/Cargo.toml b/crates/uv-requirements-txt/Cargo.toml index 7dcc2964e..fc6d335e5 100644 --- a/crates/uv-requirements-txt/Cargo.toml +++ b/crates/uv-requirements-txt/Cargo.toml @@ -26,7 +26,7 @@ uv-pypi-types = { workspace = true } uv-warnings = { workspace = true } fs-err = { workspace = true } -regex = { workspace = true } +memchr = { workspace = true } reqwest = { workspace = true, optional = true } reqwest-middleware = { workspace = true, optional = true } thiserror = { workspace = true } @@ -43,6 +43,7 @@ assert_fs = { version = "1.1.2" } indoc = { workspace = true } insta = { version = "1.40.0", features = ["filters"] } itertools = { version = "0.14.0" } +regex = { workspace = true } tempfile = { workspace = true } test-case = { version = "3.3.1" } tokio = { version = "1.40.0" } diff --git a/crates/uv-requirements-txt/src/lib.rs b/crates/uv-requirements-txt/src/lib.rs index 8840dcc18..4307ca11d 100644 --- a/crates/uv-requirements-txt/src/lib.rs +++ b/crates/uv-requirements-txt/src/lib.rs @@ -523,7 +523,10 @@ fn parse_entry( let start = s.cursor(); Ok(Some(if s.eat_if("-r") || s.eat_if("--requirement") { let filename = parse_value(content, s, |c: char| !is_terminal(c))?; - let filename = unquote(filename).unwrap_or_else(|_| filename.to_string()); + let filename = unquote(filename) + .ok() + .flatten() + .unwrap_or_else(|| filename.to_string()); let end = s.cursor(); RequirementsTxtStatement::Requirements { filename, @@ -532,7 +535,10 @@ fn parse_entry( } } else if s.eat_if("-c") || s.eat_if("--constraint") { let filename = parse_value(content, s, |c: char| !is_terminal(c))?; - let filename = unquote(filename).unwrap_or_else(|_| filename.to_string()); + let filename = unquote(filename) + .ok() + .flatten() + .unwrap_or_else(|| filename.to_string()); let end = s.cursor(); RequirementsTxtStatement::Constraint { filename, @@ -577,6 +583,8 @@ fn parse_entry( } else if s.eat_if("-i") || s.eat_if("--index-url") { let given = parse_value(content, s, |c: char| !is_terminal(c))?; let given = unquote(given) + .ok() + .flatten() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(given)); let expanded = expand_env_vars(given.as_ref()); @@ -606,6 +614,8 @@ fn parse_entry( } else if s.eat_if("--extra-index-url") { let given = parse_value(content, s, |c: char| !is_terminal(c))?; let given = unquote(given) + .ok() + .flatten() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(given)); let expanded = expand_env_vars(given.as_ref()); @@ -637,6 +647,8 @@ fn parse_entry( } else if s.eat_if("--find-links") || s.eat_if("-f") { let given = parse_value(content, s, |c: char| !is_terminal(c))?; let given = unquote(given) + .ok() + .flatten() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(given)); let expanded = expand_env_vars(given.as_ref()); @@ -666,6 +678,8 @@ fn parse_entry( } else if s.eat_if("--no-binary") { let given = parse_value(content, s, |c: char| !is_terminal(c))?; let given = unquote(given) + .ok() + .flatten() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(given)); let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| { @@ -680,6 +694,8 @@ fn parse_entry( } else if s.eat_if("--only-binary") { let given = parse_value(content, s, |c: char| !is_terminal(c))?; let given = unquote(given) + .ok() + .flatten() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(given)); let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| { diff --git a/crates/uv-requirements-txt/src/shquote.rs b/crates/uv-requirements-txt/src/shquote.rs index 27eecb065..d30b4bc5b 100644 --- a/crates/uv-requirements-txt/src/shquote.rs +++ b/crates/uv-requirements-txt/src/shquote.rs @@ -142,12 +142,20 @@ fn unquote_open_escape(acc: &mut String, cursor: &mut std::iter::Enumerate Result { +pub(crate) fn unquote(source: &str) -> Result, UnquoteError> { + // If the string does not contain any single-quotes, double-quotes, or escape sequences, it + // does not require any unquoting. + if memchr::memchr3(b'\'', b'"', b'\\', source.as_bytes()).is_none() { + return Ok(None); + } + // An unquote-operation never results in a longer string. Furthermore, the common case is // most of the string is unquoted / unescaped. Hence, we simply allocate the same space // for the resulting string as the input. @@ -182,7 +190,7 @@ pub(crate) fn unquote(source: &str) -> Result { acc.push(next_ch); } None => { - break Ok(acc); + break Ok(Some(acc)); } } } @@ -194,10 +202,10 @@ mod tests { #[test] fn basic() { - assert_eq!(unquote("foobar").unwrap(), "foobar"); - assert_eq!(unquote("foo'bar'").unwrap(), "foobar"); - assert_eq!(unquote("foo\"bar\"").unwrap(), "foobar"); - assert_eq!(unquote("\\foobar\\").unwrap(), "foobar"); - assert_eq!(unquote("\\'foobar\\'").unwrap(), "'foobar'"); + assert_eq!(unquote("foobar").unwrap(), None); + assert_eq!(unquote("foo'bar'").unwrap().unwrap(), "foobar"); + assert_eq!(unquote("foo\"bar\"").unwrap().unwrap(), "foobar"); + assert_eq!(unquote("\\foobar\\").unwrap().unwrap(), "foobar"); + assert_eq!(unquote("\\'foobar\\'").unwrap().unwrap(), "'foobar'"); } }