const SQ: u8 = b'\'';
const REQUOTE: [u8; 5] = [b'\'', b'"', b'\'', b'"', b'\''];
pub fn escape_bytes_len(b: &[u8]) -> usize {
if b.is_empty() {
return 2;
}
if is_safe_unquoted_bytes(b) {
return b.len();
}
let mut n = 2;
let mut i = 0;
while i < b.len() {
n += if b[i] == SQ { REQUOTE.len() } else { 1 };
i += 1;
}
n
}
pub fn escape_bytes_into(b: &[u8], out: &mut [u8]) -> Option<usize> {
if out.len() < escape_bytes_len(b) {
return None;
}
if b.is_empty() {
out[0] = SQ;
out[1] = SQ;
return Some(2);
}
if is_safe_unquoted_bytes(b) {
out[..b.len()].copy_from_slice(b);
return Some(b.len());
}
Some(write_single_quoted(b, out))
}
fn write_single_quoted(b: &[u8], out: &mut [u8]) -> usize {
let mut w = 0;
out[w] = SQ;
w += 1;
let mut i = 0;
while i < b.len() {
if b[i] == SQ {
out[w..w + REQUOTE.len()].copy_from_slice(&REQUOTE);
w += REQUOTE.len();
} else {
out[w] = b[i];
w += 1;
}
i += 1;
}
out[w] = SQ;
w + 1
}
pub fn is_safe_unquoted_bytes(b: &[u8]) -> bool {
match b.first() {
None => false,
Some(&f) if !is_safe_unquoted_lead(f) => false,
Some(_) => all_bytes_safe(b),
}
}
fn all_bytes_safe(b: &[u8]) -> bool {
let mut i = 0;
while i < b.len() {
if !is_safe_unquoted_byte(b[i]) {
return false;
}
i += 1;
}
true
}
fn is_safe_unquoted_lead(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'/'
}
fn is_safe_unquoted_byte(b: u8) -> bool {
if !b.is_ascii() || b.is_ascii_control() {
return false;
}
b.is_ascii_alphanumeric() || is_safe_unquoted_punct(b)
}
fn is_safe_unquoted_punct(b: u8) -> bool {
matches!(b, b'_' | b'.' | b'/' | b'-' | b'+' | b'=' | b':' | b'@')
}
pub fn escape_shell_len(s: &str) -> usize {
escape_bytes_len(s.as_bytes())
}
pub fn escape_shell_into(s: &str, out: &mut [u8]) -> Option<usize> {
escape_bytes_into(s.as_bytes(), out)
}
pub fn is_valid_shell_identifier_bytes(b: &[u8]) -> bool {
match b.first() {
None => false,
Some(&f) if !(f.is_ascii_alphabetic() || f == b'_') => false,
Some(_) => all_ident_tail_bytes_valid(b),
}
}
fn all_ident_tail_bytes_valid(b: &[u8]) -> bool {
let mut i = 1;
while i < b.len() {
if !(b[i].is_ascii_alphanumeric() || b[i] == b'_') {
return false;
}
i += 1;
}
true
}
fn sanitized_ident_byte(b: u8, first: bool) -> u8 {
let keep = if first {
b.is_ascii_alphabetic() || b == b'_'
} else {
b.is_ascii_alphanumeric() || b == b'_'
};
if keep {
b
} else {
b'_'
}
}
pub fn escape_variable_bytes_into(name: &[u8], out: &mut [u8]) -> Option<usize> {
if out.len() < name.len() || !name.is_ascii() {
return None;
}
if is_valid_shell_identifier_bytes(name) {
out[..name.len()].copy_from_slice(name);
return Some(name.len());
}
let mut i = 0;
while i < name.len() {
out[i] = sanitized_ident_byte(name[i], i == 0);
i += 1;
}
Some(name.len())
}
pub fn shell_escape(s: &str) -> String {
escape_shell_string(s)
}
pub fn escape_shell_string(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
contract_pre_roundtrip!(s);
let mut buf = vec![0u8; escape_shell_len(s)];
let n = escape_shell_into(s, &mut buf).expect("buffer was sized by escape_shell_len");
buf.truncate(n);
String::from_utf8(buf)
.expect("escape_bytes_into copies whole UTF-8 sequences and inserts only ASCII")
}
pub fn escape_variable_name(name: &str) -> String {
contract_pre_roundtrip!(name);
let bytes = name.as_bytes();
if bytes.is_ascii() {
let mut buf = vec![0u8; bytes.len()];
let n = escape_variable_bytes_into(bytes, &mut buf)
.expect("input is ASCII and the buffer is exactly name.len()");
buf.truncate(n);
return String::from_utf8(buf).expect("sanitised identifier bytes are ASCII");
}
sanitize_identifier_chars(name)
}
fn sanitize_identifier_chars(name: &str) -> String {
let mut result = String::new();
for (i, c) in name.chars().enumerate() {
let keep = if i == 0 {
c.is_ascii_alphabetic() || c == '_'
} else {
c.is_ascii_alphanumeric() || c == '_'
};
result.push(if keep { c } else { '_' });
}
result
}
pub fn escape_command_name(cmd: &str) -> String {
contract_pre_roundtrip!(cmd);
if is_safe_command_name(cmd) {
cmd.to_string()
} else {
escape_shell_string(cmd)
}
}
#[cfg(test)]
fn is_safe_unquoted(s: &str) -> bool {
is_safe_unquoted_bytes(s.as_bytes())
}
fn is_safe_command_name(cmd: &str) -> bool {
if cmd.is_empty() {
return false;
}
cmd.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
&& !cmd.starts_with('-') }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_simple_string() {
assert_eq!(escape_shell_string("hello"), "hello");
assert_eq!(escape_shell_string("hello world"), "'hello world'");
assert_eq!(escape_shell_string(""), "''");
}
#[test]
fn test_escape_string_with_quotes() {
assert_eq!(escape_shell_string("don't"), "'don'\"'\"'t'");
}
#[test]
fn test_variable_name_escaping() {
assert_eq!(escape_variable_name("valid_name"), "valid_name");
assert_eq!(escape_variable_name("invalid-name"), "invalid_name");
assert_eq!(escape_variable_name("123invalid"), "_23invalid");
}
#[test]
fn test_command_name_escaping() {
assert_eq!(escape_command_name("ls"), "ls");
assert_eq!(escape_command_name("/bin/ls"), "/bin/ls");
assert_eq!(escape_command_name("my command"), "'my command'");
}
#[test]
fn test_safe_unquoted() {
assert!(is_safe_unquoted("simple"));
assert!(is_safe_unquoted("path/to/file"));
assert!(is_safe_unquoted("version-1.0"));
assert!(!is_safe_unquoted("has spaces"));
assert!(!is_safe_unquoted("has$dollar"));
assert!(!is_safe_unquoted(""));
}
}