Skip to content

Commit a2a803f

Browse files
senamakelmedullabot
andcommitted
refactor: separate heredoc header lexical state
Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 9257240 commit a2a803f

2 files changed

Lines changed: 115 additions & 92 deletions

File tree

‎crates/tinysecurity-policy/src/command_shell/scan.rs‎

Lines changed: 114 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,10 @@ pub(crate) fn strip_quoted_heredoc_bodies(command: &str) -> Cow<'_, str> {
319319

320320
let mut pending = std::collections::VecDeque::new();
321321
let mut out = String::with_capacity(command.len());
322-
let mut quote = QuoteState::None;
323-
let mut token_boundary = true;
322+
let mut header = HeredocHeader {
323+
quote: QuoteState::None,
324+
token_boundary: true,
325+
};
324326
let mut reading_bodies = false;
325327
for line in command.split_inclusive('\n') {
326328
if reading_bodies && let Some((delim, strip_tabs, quoted)) = pending.front() {
@@ -343,103 +345,123 @@ pub(crate) fn strip_quoted_heredoc_bodies(command: &str) -> Cow<'_, str> {
343345
continue;
344346
}
345347
out.push_str(line);
346-
let mut chars = line.char_indices().peekable();
347-
while let Some((idx, ch)) = chars.next() {
348-
match quote {
349-
QuoteState::Single => {
350-
if ch == '\'' {
351-
quote = QuoteState::None;
352-
}
348+
let Some(logical_newline) = scan_heredoc_header(line, &mut header, &mut pending) else {
349+
return Cow::Borrowed(command);
350+
};
351+
reading_bodies = logical_newline && !pending.is_empty();
352+
}
353+
if out == command {
354+
Cow::Borrowed(command)
355+
} else {
356+
Cow::Owned(out)
357+
}
358+
}
359+
360+
struct HeredocHeader {
361+
quote: QuoteState,
362+
token_boundary: bool,
363+
}
364+
365+
// Physical lines can continue a word or quote. Only an unquoted, unescaped
366+
// newline ends the command header and starts consuming its ordered bodies.
367+
fn scan_heredoc_header(
368+
line: &str,
369+
header: &mut HeredocHeader,
370+
pending: &mut std::collections::VecDeque<(String, bool, bool)>,
371+
) -> Option<bool> {
372+
let mut logical_newline = false;
373+
let mut chars = line.char_indices().peekable();
374+
while let Some((idx, ch)) = chars.next() {
375+
match header.quote {
376+
QuoteState::Single => {
377+
if ch == '\'' {
378+
header.quote = QuoteState::None;
353379
}
354-
QuoteState::Double => match ch {
355-
// POSIX double quotes escape only these characters. Consuming
356-
// a continued newline leaves the command and quote open.
357-
'\\' if chars
358-
.peek()
359-
.is_some_and(|(_, c)| matches!(c, '$' | '`' | '"' | '\\' | '\n')) =>
360-
{
361-
chars.next();
362-
}
363-
'"' => quote = QuoteState::None,
364-
'`' => return Cow::Borrowed(command),
365-
'$' if chars.peek().is_some_and(|(_, c)| *c == '{') => {
366-
return Cow::Borrowed(command);
367-
}
368-
'$' if chars.peek().is_some_and(|(_, c)| *c == '(') => {
369-
if !skip_simple_substitution(&mut chars) {
370-
return Cow::Borrowed(command);
371-
}
372-
}
373-
_ => {}
374-
},
375-
QuoteState::None => match ch {
376-
'\\' => {
377-
if let Some((_, next)) = chars.next() {
378-
// Backslash-newline disappears before tokenization:
379-
// neither a token boundary nor a body boundary.
380-
if next != '\n' {
381-
token_boundary = false;
382-
}
380+
}
381+
QuoteState::Double => match ch {
382+
// POSIX double quotes escape only these characters. Consuming
383+
// a continued newline leaves the command and quote open.
384+
'\\' if chars
385+
.peek()
386+
.is_some_and(|(_, c)| matches!(c, '$' | '`' | '"' | '\\' | '\n')) =>
387+
{
388+
chars.next();
389+
}
390+
'"' => header.quote = QuoteState::None,
391+
'`' => return None,
392+
'$' if chars.peek().is_some_and(|(_, c)| *c == '{') => {
393+
return None;
394+
}
395+
'$' if chars.peek().is_some_and(|(_, c)| *c == '(')
396+
&& !skip_simple_substitution(&mut chars) =>
397+
{
398+
return None;
399+
}
400+
_ => {}
401+
},
402+
QuoteState::None => match ch {
403+
'\\' => {
404+
if let Some((_, next)) = chars.next() {
405+
// Backslash-newline disappears before tokenization:
406+
// neither a token boundary nor a body boundary.
407+
if next != '\n' {
408+
header.token_boundary = false;
383409
}
384410
}
385-
'\'' => {
386-
quote = QuoteState::Single;
387-
token_boundary = false;
388-
}
389-
'"' => {
390-
quote = QuoteState::Double;
391-
token_boundary = false;
392-
}
393-
'#' if token_boundary => {
394-
// Backslashes and quotes inside a comment are just data.
395-
reading_bodies = line.ends_with('\n') && !pending.is_empty();
396-
token_boundary = true;
397-
break;
398-
}
399-
'\n' => {
400-
reading_bodies = !pending.is_empty();
401-
token_boundary = true;
402-
}
403-
'<' if chars.next_if(|(_, c)| *c == '<').is_some() => {
404-
if chars.next_if(|(_, c)| *c == '<').is_some() {
405-
token_boundary = true;
406-
continue;
407-
}
408-
let rest = &line[idx + 2..];
409-
let Some((delim, consumed, quoted)) = heredoc_delimiter(rest) else {
410-
// Unsupported quote/expansion syntax cannot safely
411-
// establish where any queued body ends.
412-
return Cow::Borrowed(command);
413-
};
414-
pending.push_back((delim, rest.starts_with('-'), quoted));
415-
for _ in 0..consumed {
416-
chars.next();
417-
}
418-
token_boundary = false;
411+
}
412+
'\'' => {
413+
header.quote = QuoteState::Single;
414+
header.token_boundary = false;
415+
}
416+
'"' => {
417+
header.quote = QuoteState::Double;
418+
header.token_boundary = false;
419+
}
420+
'#' if header.token_boundary => {
421+
// Backslashes and quotes inside a comment are just data.
422+
logical_newline = line.ends_with('\n') && !pending.is_empty();
423+
header.token_boundary = true;
424+
break;
425+
}
426+
'\n' => {
427+
logical_newline = !pending.is_empty();
428+
header.token_boundary = true;
429+
}
430+
'<' if chars.next_if(|(_, c)| *c == '<').is_some() => {
431+
if chars.next_if(|(_, c)| *c == '<').is_some() {
432+
header.token_boundary = true;
433+
continue;
419434
}
420-
'`' => return Cow::Borrowed(command),
421-
'$' if chars
422-
.peek()
423-
.is_some_and(|(_, c)| matches!(c, '{' | '\'' | '"')) =>
424-
{
425-
return Cow::Borrowed(command);
435+
let rest = &line[idx + 2..];
436+
let Some((delim, consumed, quoted)) = heredoc_delimiter(rest) else {
437+
// Unsupported quote/expansion syntax cannot safely
438+
// establish where any queued body ends.
439+
return None;
440+
};
441+
pending.push_back((delim, rest.starts_with('-'), quoted));
442+
for _ in 0..consumed {
443+
chars.next();
426444
}
427-
'$' if chars.peek().is_some_and(|(_, c)| *c == '(') => {
428-
if !skip_simple_substitution(&mut chars) {
429-
return Cow::Borrowed(command);
430-
}
431-
token_boundary = false;
445+
header.token_boundary = false;
446+
}
447+
'`' => return None,
448+
'$' if chars
449+
.peek()
450+
.is_some_and(|(_, c)| matches!(c, '{' | '\'' | '"')) =>
451+
{
452+
return None;
453+
}
454+
'$' if chars.peek().is_some_and(|(_, c)| *c == '(') => {
455+
if !skip_simple_substitution(&mut chars) {
456+
return None;
432457
}
433-
_ => token_boundary = ch.is_whitespace() || ";&|<>()".contains(ch),
434-
},
435-
}
458+
header.token_boundary = false;
459+
}
460+
_ => header.token_boundary = ch.is_whitespace() || ";&|<>()".contains(ch),
461+
},
436462
}
437463
}
438-
if out == command {
439-
Cow::Borrowed(command)
440-
} else {
441-
Cow::Owned(out)
442-
}
464+
Some(logical_newline)
443465
}
444466

445467
// Skip only a flat command substitution whose text cannot declare a heredoc or
@@ -449,7 +471,7 @@ fn skip_simple_substitution(chars: &mut std::iter::Peekable<std::str::CharIndice
449471
for (_, ch) in chars.by_ref() {
450472
match ch {
451473
')' => return true,
452-
'\n' | '\\' | '\'' | '"' | '`' | '$' | '<' | '(' => return false,
474+
'\n' | '\\' | '\'' | '"' | '`' | '$' | '<' | '(' | '#' => return false,
453475
_ => {}
454476
}
455477
}

‎crates/tinysecurity-policy/src/command_shell_scan_tests.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ fn unsupported_header_syntax_preserves_all_command_text() {
399399
"cat <<'A' $(echo <<'NESTED')",
400400
"cat <<'A' \"$(echo <<'NESTED')\"",
401401
"cat <<'A' $(unfinished",
402+
"cat <<'A' $(echo safe # ) <<'fake')",
402403
"cat <<'A' `echo literal`",
403404
"cat <<'A' \"`echo literal`\"",
404405
"cat <<'A' ${value:-<<'NESTED'}",

0 commit comments

Comments
 (0)