mirror of
https://github.com/sxyazi/yazi.git
synced 2026-07-25 08:41:05 +00:00
feat: ya.quote() support escaping strings containing invalid UTF-8 (#3369)
This commit is contained in:
parent
2d55c9427d
commit
a1fb206a59
15 changed files with 179 additions and 241 deletions
|
|
@ -30,7 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
|
||||||
- Allow dynamic adjustment of layout ratio via `rt.mgr.ratio` ([#2964])
|
- Allow dynamic adjustment of layout ratio via `rt.mgr.ratio` ([#2964])
|
||||||
- Support `.deb` packages ([#2807], [#3128], [#3209])
|
- Support `.deb` packages ([#2807], [#3128], [#3209])
|
||||||
- Port several widespread GUI keys to the input component ([#2849])
|
- Port several widespread GUI keys to the input component ([#2849])
|
||||||
- Support invalid UTF-8 paths throughout the codebase ([#2884], [#2889], [#2890], [#2895], [#3023], [#3290])
|
- Support invalid UTF-8 paths throughout the codebase ([#2884], [#2889], [#2890], [#2895], [#3023], [#3290], [#3369])
|
||||||
- Allow upgrading only specific packages with `ya pkg` ([#2841])
|
- Allow upgrading only specific packages with `ya pkg` ([#2841])
|
||||||
- Respect the user's `image_filter` setting in the preset ImageMagick previewer ([#3286])
|
- Respect the user's `image_filter` setting in the preset ImageMagick previewer ([#3286])
|
||||||
- Allow custom mouse click behavior for individual files ([#2925])
|
- Allow custom mouse click behavior for individual files ([#2925])
|
||||||
|
|
@ -1542,3 +1542,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
|
||||||
[#3317]: https://github.com/sxyazi/yazi/pull/3317
|
[#3317]: https://github.com/sxyazi/yazi/pull/3317
|
||||||
[#3360]: https://github.com/sxyazi/yazi/pull/3360
|
[#3360]: https://github.com/sxyazi/yazi/pull/3360
|
||||||
[#3364]: https://github.com/sxyazi/yazi/pull/3364
|
[#3364]: https://github.com/sxyazi/yazi/pull/3364
|
||||||
|
[#3369]: https://github.com/sxyazi/yazi/pull/3369
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ impl Path {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_prefix(&self, base: Value) -> mlua::Result<Option<Path>> {
|
fn strip_prefix(&self, base: Value) -> mlua::Result<Option<Self>> {
|
||||||
let strip = match base {
|
let strip = match base {
|
||||||
Value::String(s) => self.try_strip_prefix(StrandCow::with(self.kind(), &*s.as_bytes())?),
|
Value::String(s) => self.try_strip_prefix(StrandCow::with(self.kind(), &*s.as_bytes())?),
|
||||||
Value::UserData(ud) => self.try_strip_prefix(&*ud.borrow::<Self>()?),
|
Value::UserData(ud) => self.try_strip_prefix(&*ud.borrow::<Self>()?),
|
||||||
|
|
@ -87,7 +87,7 @@ impl Path {
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(match strip {
|
Ok(match strip {
|
||||||
Ok(p) => Some(Path::new(p)),
|
Ok(p) => Some(Self::new(p)),
|
||||||
Err(StripPrefixError::Exotic | StripPrefixError::NotPrefix) => None,
|
Err(StripPrefixError::Exotic | StripPrefixError::NotPrefix) => None,
|
||||||
Err(e @ StripPrefixError::WrongEncoding) => Err(e.into_lua_err())?,
|
Err(e @ StripPrefixError::WrongEncoding) => Err(e.into_lua_err())?,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ impl Url {
|
||||||
match other {
|
match other {
|
||||||
Value::String(s) => {
|
Value::String(s) => {
|
||||||
let b = s.as_bytes();
|
let b = s.as_bytes();
|
||||||
let (scheme, path) = SchemeCow::parse(&*b)?;
|
let (scheme, path) = SchemeCow::parse(&b)?;
|
||||||
if scheme == self.scheme() {
|
if scheme == self.scheme() {
|
||||||
Self::new(self.try_join(path).into_lua_err()?).into_lua(lua)
|
Self::new(self.try_join(path).into_lua_err()?).into_lua(lua)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ impl TryFrom<CmdCow> for PluginOpt {
|
||||||
};
|
};
|
||||||
|
|
||||||
let args = if let Ok(s) = c.second() {
|
let args = if let Ok(s) = c.second() {
|
||||||
let (words, last) = yazi_shared::shell::split_unix(s, true)?;
|
let (words, last) = yazi_shared::shell::unix::split(s, true)?;
|
||||||
Cmd::parse_args(words, last)?
|
Cmd::parse_args(words, last)?
|
||||||
} else {
|
} else {
|
||||||
Default::default()
|
Default::default()
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ impl TryFrom<CmdCow> for SearchOpt {
|
||||||
(c.str(0).parse()?, "".into())
|
(c.str(0).parse()?, "".into())
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(args) = yazi_shared::shell::split_unix(c.str("args"), false) else {
|
let Ok(args) = yazi_shared::shell::unix::split(c.str("args"), false) else {
|
||||||
bail!("Invalid 'args' argument in SearchOpt");
|
bail!("Invalid 'args' argument in SearchOpt");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,11 @@ impl UserData for Command {
|
||||||
let mut me = ud.borrow_mut::<Self>()?;
|
let mut me = ud.borrow_mut::<Self>()?;
|
||||||
match arg {
|
match arg {
|
||||||
Value::String(s) => {
|
Value::String(s) => {
|
||||||
me.inner.arg(OsStr::from_wtf8(&*s.as_bytes())?);
|
me.inner.arg(OsStr::from_wtf8(&s.as_bytes())?);
|
||||||
}
|
}
|
||||||
Value::Table(t) => {
|
Value::Table(t) => {
|
||||||
for s in t.sequence_values::<mlua::String>() {
|
for s in t.sequence_values::<mlua::String>() {
|
||||||
me.inner.arg(OsStr::from_wtf8(&*s?.as_bytes())?);
|
me.inner.arg(OsStr::from_wtf8(&s?.as_bytes())?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => return Err("arg must be a string or table of strings".into_lua_err()),
|
_ => return Err("arg must be a string or table of strings".into_lua_err()),
|
||||||
|
|
@ -165,7 +165,7 @@ impl UserData for Command {
|
||||||
|_, (ud, key, value): (AnyUserData, mlua::String, mlua::String)| {
|
|_, (ud, key, value): (AnyUserData, mlua::String, mlua::String)| {
|
||||||
ud.borrow_mut::<Self>()?
|
ud.borrow_mut::<Self>()?
|
||||||
.inner
|
.inner
|
||||||
.env(OsStr::from_wtf8(&*key.as_bytes())?, OsStr::from_wtf8(&*value.as_bytes())?);
|
.env(OsStr::from_wtf8(&key.as_bytes())?, OsStr::from_wtf8(&value.as_bytes())?);
|
||||||
Ok(ud)
|
Ok(ud)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ impl Utils {
|
||||||
|
|
||||||
pub(super) fn quote(lua: &Lua) -> mlua::Result<Function> {
|
pub(super) fn quote(lua: &Lua) -> mlua::Result<Function> {
|
||||||
lua.create_function(|lua, (s, unix): (mlua::String, Option<bool>)| {
|
lua.create_function(|lua, (s, unix): (mlua::String, Option<bool>)| {
|
||||||
let s = s.to_str()?;
|
let b = s.as_bytes();
|
||||||
let s = match unix {
|
let s = match unix {
|
||||||
Some(true) => yazi_shared::shell::escape_unix(s.as_ref()),
|
Some(true) => yazi_shared::shell::unix::escape_os_bytes(&b),
|
||||||
Some(false) => yazi_shared::shell::escape_windows(s.as_ref()),
|
Some(false) => yazi_shared::shell::windows::escape_os_bytes(&b),
|
||||||
None => yazi_shared::shell::escape_native(s.as_ref()),
|
None => yazi_shared::shell::escape_os_bytes(&b),
|
||||||
};
|
};
|
||||||
lua.create_string(&*s)
|
lua.create_string(&*s)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::{ffi::OsString, process::Stdio};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
use yazi_fs::{Cwd, FsUrl};
|
use yazi_fs::Cwd;
|
||||||
use yazi_shared::url::{AsUrl, UrlCow};
|
use yazi_shared::url::{AsUrl, UrlCow};
|
||||||
|
|
||||||
pub(crate) struct ShellOpt {
|
pub(crate) struct ShellOpt {
|
||||||
|
|
@ -32,6 +32,7 @@ pub(crate) async fn shell(opt: ShellOpt) -> Result<Child> {
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
return Ok(unsafe {
|
return Ok(unsafe {
|
||||||
|
use yazi_fs::FsUrl;
|
||||||
use yazi_shared::url::AsUrl;
|
use yazi_shared::url::AsUrl;
|
||||||
|
|
||||||
Command::new("sh")
|
Command::new("sh")
|
||||||
|
|
@ -59,8 +60,10 @@ pub(crate) async fn shell(opt: ShellOpt) -> Result<Child> {
|
||||||
.stdin(opt.stdio())
|
.stdin(opt.stdio())
|
||||||
.stdout(opt.stdio())
|
.stdout(opt.stdio())
|
||||||
.stderr(opt.stdio())
|
.stderr(opt.stdio())
|
||||||
.raw_arg("/C")
|
.env("=", r#""^\n\n""#)
|
||||||
|
.raw_arg(r#"/Q /S /D /V:OFF /E:ON /C ""#)
|
||||||
.raw_arg(opt.cmd)
|
.raw_arg(opt.cmd)
|
||||||
|
.raw_arg(r#"""#)
|
||||||
.current_dir(cwd)
|
.current_dir(cwd)
|
||||||
.kill_on_drop(!opt.orphan)
|
.kill_on_drop(!opt.orphan)
|
||||||
.spawn()?,
|
.spawn()?,
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ impl FromStr for Cmd {
|
||||||
type Err = anyhow::Error;
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
let (mut words, last) = crate::shell::split_unix(s, true)?;
|
let (mut words, last) = crate::shell::unix::split(s, true)?;
|
||||||
if words.is_empty() || words[0].is_empty() {
|
if words.is_empty() || words[0].is_empty() {
|
||||||
bail!("command name cannot be empty");
|
bail!("command name cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
yazi-shared/src/shell/error.rs
Normal file
11
yazi-shared/src/shell/error.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SplitError {
|
||||||
|
#[error("missing closing single quote")]
|
||||||
|
MissingSingleQuote,
|
||||||
|
#[error("missing closing double quote")]
|
||||||
|
MissingDoubleQuote,
|
||||||
|
#[error("missing quote after escape slash")]
|
||||||
|
MissingQuoteAfterSlash,
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
//! Escape characters that may have special meaning in a shell, including
|
//! Escape characters that may have special meaning in a shell, including
|
||||||
//! spaces. This is a modified version of the [`shell-escape`], [`shell-words`]
|
//! spaces. This is a modified version of the [`shell-escape`], [`shell-words`],
|
||||||
//! and [`this PR`].
|
//! [Rust std] and [this PR].
|
||||||
//!
|
//!
|
||||||
//! [`shell-escape`]: https://crates.io/crates/shell-escape
|
//! [`shell-escape`]: https://crates.io/crates/shell-escape
|
||||||
//! [`shell-words`]: https://crates.io/crates/shell-words
|
//! [`shell-words`]: https://crates.io/crates/shell-words
|
||||||
//! [`this PR`]: https://github.com/sfackler/shell-escape/pull/9
|
//! [Rust std]: https://github.com/rust-lang/rust/blob/main/library/std/src/sys/args/windows.rs#L220
|
||||||
|
//! [this PR]: https://github.com/sfackler/shell-escape/pull/9
|
||||||
|
|
||||||
use std::{borrow::Cow, ffi::OsStr};
|
use std::{borrow::Cow, ffi::OsStr};
|
||||||
|
|
||||||
mod unix;
|
yazi_macro::mod_pub!(unix, windows);
|
||||||
mod windows;
|
|
||||||
|
yazi_macro::mod_flat!(error);
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn escape_unix(s: &str) -> Cow<'_, str> { unix::escape_str(s) }
|
pub fn escape_os_bytes(b: &[u8]) -> Cow<'_, [u8]> {
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn escape_windows(s: &str) -> Cow<'_, str> { windows::escape_str(s) }
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn escape_native(s: &str) -> Cow<'_, str> {
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
escape_unix(s)
|
unix::escape_os_bytes(b)
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
escape_windows(s)
|
windows::escape_os_bytes(b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,22 +36,3 @@ pub fn escape_os_str(s: &OsStr) -> Cow<'_, OsStr> {
|
||||||
windows::escape_os_str(s)
|
windows::escape_os_str(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn split_unix(s: &str, eoo: bool) -> anyhow::Result<(Vec<String>, Option<String>)> {
|
|
||||||
unix::split(s, eoo).map_err(|()| anyhow::anyhow!("missing closing quote"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub fn split_windows(s: &str) -> anyhow::Result<Vec<String>> { Ok(windows::split(s)?) }
|
|
||||||
|
|
||||||
pub fn split_native(s: &str) -> anyhow::Result<Vec<String>> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
Ok(split_unix(s, false)?.0)
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
split_windows(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,25 @@
|
||||||
use std::{borrow::Cow, mem};
|
use std::{borrow::Cow, mem};
|
||||||
|
|
||||||
pub fn escape_str(s: &str) -> Cow<'_, str> {
|
use crate::shell::SplitError;
|
||||||
match escape_slice(s.as_bytes()) {
|
|
||||||
Cow::Borrowed(_) => Cow::Borrowed(s),
|
pub fn escape_os_bytes(b: &[u8]) -> Cow<'_, [u8]> {
|
||||||
Cow::Owned(v) => String::from_utf8(v).expect("Invalid bytes returned by escape_slice()").into(),
|
if !b.is_empty() && b.iter().copied().all(allowed) {
|
||||||
}
|
return Cow::Borrowed(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
let mut escaped = Vec::with_capacity(b.len() + 2);
|
||||||
pub fn escape_os_str(s: &std::ffi::OsStr) -> Cow<'_, std::ffi::OsStr> {
|
|
||||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
|
||||||
|
|
||||||
match escape_slice(s.as_bytes()) {
|
|
||||||
Cow::Borrowed(_) => Cow::Borrowed(s),
|
|
||||||
Cow::Owned(v) => std::ffi::OsString::from_vec(v).into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_slice(s: &[u8]) -> Cow<'_, [u8]> {
|
|
||||||
if !s.is_empty() && s.iter().copied().all(allowed) {
|
|
||||||
return Cow::Borrowed(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut escaped = Vec::with_capacity(s.len() + 2);
|
|
||||||
escaped.push(b'\'');
|
escaped.push(b'\'');
|
||||||
|
|
||||||
for &b in s {
|
for &c in b {
|
||||||
match b {
|
match c {
|
||||||
b'\'' | b'!' => {
|
b'\'' | b'!' => {
|
||||||
escaped.reserve(4);
|
escaped.reserve(4);
|
||||||
escaped.push(b'\'');
|
escaped.push(b'\'');
|
||||||
escaped.push(b'\\');
|
escaped.push(b'\\');
|
||||||
escaped.push(b);
|
escaped.push(c);
|
||||||
escaped.push(b'\'');
|
escaped.push(b'\'');
|
||||||
}
|
}
|
||||||
_ => escaped.push(b),
|
_ => escaped.push(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,11 +27,21 @@ fn escape_slice(s: &[u8]) -> Cow<'_, [u8]> {
|
||||||
escaped.into()
|
escaped.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn escape_os_str(s: &std::ffi::OsStr) -> Cow<'_, std::ffi::OsStr> {
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
|
||||||
|
match escape_os_bytes(s.as_bytes()) {
|
||||||
|
Cow::Borrowed(_) => Cow::Borrowed(s),
|
||||||
|
Cow::Owned(v) => std::ffi::OsString::from_vec(v).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn allowed(b: u8) -> bool {
|
fn allowed(b: u8) -> bool {
|
||||||
matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'=' | b'/' | b',' | b'.' | b'+')
|
matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'=' | b'/' | b',' | b'.' | b'+')
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn split(s: &str, eoo: bool) -> Result<(Vec<String>, Option<String>), ()> {
|
pub fn split(s: &str, eoo: bool) -> Result<(Vec<String>, Option<String>), SplitError> {
|
||||||
enum State {
|
enum State {
|
||||||
/// Within a delimiter.
|
/// Within a delimiter.
|
||||||
Delimiter,
|
Delimiter,
|
||||||
|
|
@ -138,7 +133,7 @@ pub fn split(s: &str, eoo: bool) -> Result<(Vec<String>, Option<String>), ()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
SingleQuoted => match c {
|
SingleQuoted => match c {
|
||||||
None => return Err(()),
|
None => return Err(SplitError::MissingSingleQuote),
|
||||||
Some('\'') => Unquoted,
|
Some('\'') => Unquoted,
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
word.push(c);
|
word.push(c);
|
||||||
|
|
@ -146,7 +141,7 @@ pub fn split(s: &str, eoo: bool) -> Result<(Vec<String>, Option<String>), ()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DoubleQuoted => match c {
|
DoubleQuoted => match c {
|
||||||
None => return Err(()),
|
None => return Err(SplitError::MissingDoubleQuote),
|
||||||
Some('\"') => Unquoted,
|
Some('\"') => Unquoted,
|
||||||
Some('\\') => DoubleQuotedBackslash,
|
Some('\\') => DoubleQuotedBackslash,
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
|
|
@ -155,7 +150,7 @@ pub fn split(s: &str, eoo: bool) -> Result<(Vec<String>, Option<String>), ()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DoubleQuotedBackslash => match c {
|
DoubleQuotedBackslash => match c {
|
||||||
None => return Err(()),
|
None => return Err(SplitError::MissingQuoteAfterSlash),
|
||||||
Some('\n') => DoubleQuoted,
|
Some('\n') => DoubleQuoted,
|
||||||
Some(c @ '$') | Some(c @ '`') | Some(c @ '"') | Some(c @ '\\') => {
|
Some(c @ '$') | Some(c @ '`') | Some(c @ '"') | Some(c @ '\\') => {
|
||||||
word.push(c);
|
word.push(c);
|
||||||
|
|
@ -183,47 +178,25 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_escape_str() {
|
fn test_escape_os_bytes() {
|
||||||
assert_eq!(escape_str(""), r#"''"#);
|
let cases: &[(&[u8], &[u8])] = &[
|
||||||
assert_eq!(escape_str(" "), r#"' '"#);
|
(b"", br#"''"#),
|
||||||
assert_eq!(escape_str("*"), r#"'*'"#);
|
(b" ", br#"' '"#),
|
||||||
|
(b"*", br#"'*'"#),
|
||||||
|
(b"--aaa=bbb-ccc", b"--aaa=bbb-ccc"),
|
||||||
|
(br#"--features="default""#, br#"'--features="default"'"#),
|
||||||
|
(b"linker=gcc -L/foo -Wl,bar", br#"'linker=gcc -L/foo -Wl,bar'"#),
|
||||||
|
(
|
||||||
|
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
|
||||||
|
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
|
||||||
|
),
|
||||||
|
(br#"'!\$`\\\n "#, br#"''\'''\!'\$`\\\n '"#),
|
||||||
|
(&[0x66, 0x6f, 0x80, 0x6f], &[b'\'', 0x66, 0x6f, 0x80, 0x6f, b'\'']),
|
||||||
|
];
|
||||||
|
|
||||||
assert_eq!(escape_str("--aaa=bbb-ccc"), "--aaa=bbb-ccc");
|
for &(input, expected) in cases {
|
||||||
assert_eq!(escape_str(r#"--features="default""#), r#"'--features="default"'"#);
|
let escaped = escape_os_bytes(input);
|
||||||
assert_eq!(escape_str("linker=gcc -L/foo -Wl,bar"), r#"'linker=gcc -L/foo -Wl,bar'"#);
|
assert_eq!(escaped, expected, "Failed to escape: {:?}", String::from_utf8_lossy(input));
|
||||||
|
}
|
||||||
assert_eq!(
|
|
||||||
escape_str("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+"),
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
|
|
||||||
);
|
|
||||||
assert_eq!(escape_str(r#"'!\$`\\\n "#), r#"''\'''\!'\$`\\\n '"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn test_escape_os_str() {
|
|
||||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
|
||||||
|
|
||||||
fn from_str(input: &str, expected: &str) { from_bytes(input.as_bytes(), expected.as_bytes()) }
|
|
||||||
|
|
||||||
fn from_bytes(input: &[u8], expected: &[u8]) {
|
|
||||||
assert_eq!(escape_os_str(OsStr::from_bytes(input)), OsStr::from_bytes(expected));
|
|
||||||
}
|
|
||||||
|
|
||||||
from_str("", r#"''"#);
|
|
||||||
from_str(" ", r#"' '"#);
|
|
||||||
from_str("*", r#"'*'"#);
|
|
||||||
|
|
||||||
from_str("--aaa=bbb-ccc", "--aaa=bbb-ccc");
|
|
||||||
from_str(r#"--features="default""#, r#"'--features="default"'"#);
|
|
||||||
from_str("linker=gcc -L/foo -Wl,bar", r#"'linker=gcc -L/foo -Wl,bar'"#);
|
|
||||||
|
|
||||||
from_str(
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
|
|
||||||
);
|
|
||||||
from_str(r#"'!\$`\\\n "#, r#"''\'''\!'\$`\\\n '"#);
|
|
||||||
|
|
||||||
from_bytes(&[0x66, 0x6f, 0x80, 0x6f], &[b'\'', 0x66, 0x6f, 0x80, 0x6f, b'\'']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,68 @@
|
||||||
use std::{borrow::Cow, iter::repeat_n};
|
use std::borrow::Cow;
|
||||||
|
|
||||||
pub fn escape_str(s: &str) -> Cow<'_, str> {
|
pub fn escape_os_bytes(b: &[u8]) -> Cow<'_, [u8]> {
|
||||||
let bytes = s.as_bytes();
|
let quote = needs_quotes(b);
|
||||||
if !bytes.is_empty() && !bytes.iter().any(|&c| matches!(c, b' ' | b'"' | b'\n' | b'\t')) {
|
let mut buf = Vec::with_capacity(b.len() + quote as usize * 2);
|
||||||
return Cow::Borrowed(s);
|
|
||||||
|
if quote {
|
||||||
|
buf.push(b'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut escaped = Vec::with_capacity(bytes.len() + 2);
|
// Loop through the string, escaping `\` only if followed by `"`.
|
||||||
escaped.push(b'"');
|
// And escaping `"` by doubling them.
|
||||||
|
let mut backslashes: usize = 0;
|
||||||
let mut chars = bytes.iter().copied().peekable();
|
for &c in b {
|
||||||
loop {
|
if c == b'\\' {
|
||||||
let mut slashes = 0;
|
backslashes += 1;
|
||||||
while chars.next_if_eq(&b'\\').is_some() {
|
} else {
|
||||||
slashes += 1;
|
if c == b'"' {
|
||||||
}
|
buf.extend((0..backslashes).map(|_| b'\\'));
|
||||||
match chars.next() {
|
buf.push(b'"');
|
||||||
Some(b'"') => {
|
} else if c == b'%' {
|
||||||
escaped.reserve(slashes * 2 + 2);
|
buf.extend_from_slice(b"%%cd:~,");
|
||||||
escaped.extend(repeat_n(b'\\', slashes * 2 + 1));
|
} else if c == b'\r' || c == b'\n' {
|
||||||
escaped.push(b'"');
|
buf.extend_from_slice(b"%=%");
|
||||||
}
|
backslashes = 0;
|
||||||
Some(b) => {
|
continue;
|
||||||
escaped.reserve(slashes + 1);
|
|
||||||
escaped.extend(repeat_n(b'\\', slashes));
|
|
||||||
escaped.push(b);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
escaped.reserve(slashes * 2);
|
|
||||||
escaped.extend(repeat_n(b'\\', slashes * 2));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
backslashes = 0;
|
||||||
}
|
}
|
||||||
|
buf.push(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
escaped.push(b'"');
|
if quote {
|
||||||
Cow::Owned(unsafe { String::from_utf8_unchecked(escaped) })
|
buf.extend((0..backslashes).map(|_| b'\\'));
|
||||||
|
buf.push(b'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn escape_os_str(s: &std::ffi::OsStr) -> Cow<'_, std::ffi::OsStr> {
|
pub fn escape_os_str(s: &std::ffi::OsStr) -> Cow<'_, std::ffi::OsStr> {
|
||||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
use crate::FromWtf8Vec;
|
||||||
|
|
||||||
let wide = s.encode_wide();
|
match escape_os_bytes(s.as_encoded_bytes()) {
|
||||||
if !s.is_empty() && !wide.clone().into_iter().any(disallowed) {
|
Cow::Borrowed(_) => Cow::Borrowed(s),
|
||||||
return Cow::Borrowed(s);
|
Cow::Owned(v) => std::ffi::OsString::from_wtf8_vec(v).expect("valid WTF-8").into(),
|
||||||
}
|
|
||||||
|
|
||||||
let mut escaped: Vec<u16> = Vec::with_capacity(s.len() + 2);
|
|
||||||
escaped.push(b'"' as _);
|
|
||||||
|
|
||||||
let mut chars = wide.into_iter().peekable();
|
|
||||||
loop {
|
|
||||||
let mut slashes = 0;
|
|
||||||
while chars.next_if_eq(&(b'\\' as _)).is_some() {
|
|
||||||
slashes += 1;
|
|
||||||
}
|
|
||||||
match chars.next() {
|
|
||||||
Some(c) if c == b'"' as _ => {
|
|
||||||
escaped.reserve(slashes * 2 + 2);
|
|
||||||
escaped.extend(repeat_n(b'\\' as u16, slashes * 2 + 1));
|
|
||||||
escaped.push(b'"' as _);
|
|
||||||
}
|
|
||||||
Some(c) => {
|
|
||||||
escaped.reserve(slashes + 1);
|
|
||||||
escaped.extend(repeat_n(b'\\' as u16, slashes));
|
|
||||||
escaped.push(c);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
escaped.reserve(slashes * 2);
|
|
||||||
escaped.extend(repeat_n(b'\\' as u16, slashes * 2));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
escaped.push(b'"' as _);
|
fn needs_quotes(arg: &[u8]) -> bool {
|
||||||
std::ffi::OsString::from_wide(&escaped).into()
|
static UNQUOTED: &[u8] = br"#$*+-./:?@\_";
|
||||||
|
|
||||||
|
if arg.is_empty() || arg.last() == Some(&b'\\') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in arg {
|
||||||
|
if c.is_ascii_control() {
|
||||||
|
return true;
|
||||||
|
} else if c.is_ascii() && !(c.is_ascii_alphanumeric() || UNQUOTED.contains(c)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|
@ -84,11 +70,11 @@ pub fn split(s: &str) -> std::io::Result<Vec<String>> {
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
let s: Vec<_> = std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect();
|
let s: Vec<_> = std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect();
|
||||||
split_slice(&s)
|
split_wide(&s)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn split_slice(s: &[u16]) -> std::io::Result<Vec<String>> {
|
fn split_wide(s: &[u16]) -> std::io::Result<Vec<String>> {
|
||||||
use std::mem::MaybeUninit;
|
use std::mem::MaybeUninit;
|
||||||
|
|
||||||
use windows_sys::{Win32::{Foundation::LocalFree, UI::Shell::CommandLineToArgvW}, core::PCWSTR};
|
use windows_sys::{Win32::{Foundation::LocalFree, UI::Shell::CommandLineToArgvW}, core::PCWSTR};
|
||||||
|
|
@ -114,31 +100,36 @@ fn split_slice(s: &[u16]) -> std::io::Result<Vec<String>> {
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn disallowed(b: u16) -> bool {
|
|
||||||
match char::from_u32(b as u32) {
|
|
||||||
Some(c) => matches!(c, ' ' | '"' | '\n' | '\t'),
|
|
||||||
None => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_escape_str() {
|
fn test_escape_os_bytes() {
|
||||||
assert_eq!(escape_str(""), r#""""#);
|
let cases: &[(&[u8], &[u8])] = &[
|
||||||
assert_eq!(escape_str(r#""""#), r#""\"\"""#);
|
// Empty string
|
||||||
|
(b"", br#""""#),
|
||||||
|
(br#""""#, br#""""""""#),
|
||||||
|
// No escaping needed
|
||||||
|
(b"--aaa=bbb-ccc", br#""--aaa=bbb-ccc""#),
|
||||||
|
// Paths with spaces
|
||||||
|
(br#"\path\to\my documents\"#, br#""\path\to\my documents\\""#),
|
||||||
|
// Strings with quotes
|
||||||
|
(br#"--features="default""#, br#""--features=""default""""#),
|
||||||
|
// Nested quotes
|
||||||
|
(br#""--features=\"default\"""#, br#""""--features=\\""default\\""""""#),
|
||||||
|
// Complex command
|
||||||
|
(b"linker=gcc -L/foo -Wl,bar", br#""linker=gcc -L/foo -Wl,bar""#),
|
||||||
|
// Variable expansion
|
||||||
|
(b"%APPDATA%.txt", br#""%%cd:~,%APPDATA%%cd:~,%.txt""#),
|
||||||
|
// Unicode characters
|
||||||
|
("이것은 테스트".as_bytes(), r#""이것은 테스트""#.as_bytes()),
|
||||||
|
];
|
||||||
|
|
||||||
assert_eq!(escape_str("--aaa=bbb-ccc"), "--aaa=bbb-ccc");
|
for &(input, expected) in cases {
|
||||||
assert_eq!(escape_str(r#"\path\to\my documents\"#), r#""\path\to\my documents\\""#);
|
let escaped = escape_os_bytes(input);
|
||||||
|
assert_eq!(escaped, expected, "Failed to escape: {:?}", String::from_utf8_lossy(input));
|
||||||
assert_eq!(escape_str(r#"--features="default""#), r#""--features=\"default\"""#);
|
}
|
||||||
assert_eq!(escape_str(r#""--features=\"default\"""#), r#""\"--features=\\\"default\\\"\"""#);
|
|
||||||
assert_eq!(escape_str("linker=gcc -L/foo -Wl,bar"), r#""linker=gcc -L/foo -Wl,bar""#);
|
|
||||||
|
|
||||||
assert_eq!(escape_str("이것은 테스트"), r#""이것은 테스트""#);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|
@ -146,41 +137,23 @@ mod tests {
|
||||||
fn test_escape_os_str() {
|
fn test_escape_os_str() {
|
||||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
||||||
|
|
||||||
fn from_str(input: &str, expected: &str) {
|
#[rustfmt::skip]
|
||||||
let observed = OsString::from(input);
|
let cases: &[(OsString, OsString)] = &[
|
||||||
let expected = OsString::from(expected);
|
// Surrogate pairs and special characters
|
||||||
assert_eq!(escape_os_str(observed.as_os_str()), expected.as_os_str());
|
(
|
||||||
}
|
OsString::from_wide(&[0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065]),
|
||||||
|
OsString::from_wide(&[0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065]),
|
||||||
|
),
|
||||||
|
// Surrogate pair with quotes
|
||||||
|
(
|
||||||
|
OsString::from_wide(&[0xd801, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065]),
|
||||||
|
OsString::from_wide(&[0xd801, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
fn from_bytes(input: &[u16], expected: &[u16]) {
|
for (input, expected) in cases {
|
||||||
let observed = OsString::from_wide(input);
|
let escaped = escape_os_str(&input);
|
||||||
let expected = OsString::from_wide(expected);
|
assert_eq!(&*escaped, expected, "Failed to escape: {:?}", input.to_string_lossy());
|
||||||
assert_eq!(escape_os_str(observed.as_os_str()), expected.as_os_str());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
from_str("", r#""""#);
|
|
||||||
from_str(r#""""#, r#""\"\"""#);
|
|
||||||
|
|
||||||
from_str("--aaa=bbb-ccc", "--aaa=bbb-ccc");
|
|
||||||
from_str(r#"\path\to\my documents\"#, r#""\path\to\my documents\\""#);
|
|
||||||
|
|
||||||
from_str(r#"--features="default""#, r#""--features=\"default\"""#);
|
|
||||||
from_str(r#""--features=\"default\"""#, r#""\"--features=\\\"default\\\"\"""#);
|
|
||||||
from_str("linker=gcc -L/foo -Wl,bar", r#""linker=gcc -L/foo -Wl,bar""#);
|
|
||||||
|
|
||||||
from_bytes(&[0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065], &[
|
|
||||||
0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065,
|
|
||||||
]);
|
|
||||||
from_bytes(&[0xd801, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065], &[
|
|
||||||
b'"' as u16,
|
|
||||||
0xd801,
|
|
||||||
0x006e,
|
|
||||||
0x0069,
|
|
||||||
0x0063,
|
|
||||||
0x006f,
|
|
||||||
0x0064,
|
|
||||||
0x0065,
|
|
||||||
b'"' as u16,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,8 @@ impl AsStrand for PathDyn<'_> {
|
||||||
impl AsStrand for PathBufDyn {
|
impl AsStrand for PathBufDyn {
|
||||||
fn as_strand(&self) -> Strand<'_> {
|
fn as_strand(&self) -> Strand<'_> {
|
||||||
match self {
|
match self {
|
||||||
PathBufDyn::Os(p) => Strand::Os(p.as_os_str()),
|
Self::Os(p) => Strand::Os(p.as_os_str()),
|
||||||
PathBufDyn::Unix(p) => Strand::Bytes(p.as_bytes()),
|
Self::Unix(p) => Strand::Bytes(p.as_bytes()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ pub enum StrandKind {
|
||||||
impl From<PathKind> for StrandKind {
|
impl From<PathKind> for StrandKind {
|
||||||
fn from(value: PathKind) -> Self {
|
fn from(value: PathKind) -> Self {
|
||||||
match value {
|
match value {
|
||||||
PathKind::Os => StrandKind::Os,
|
PathKind::Os => Self::Os,
|
||||||
PathKind::Unix => StrandKind::Bytes,
|
PathKind::Unix => Self::Bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue