1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
//! Cutting a command into the pieces the credential-print rule judges
//! (#8596, #8676).
//!
//! Why: the flow scan in the parent module judges one stage at a time, with
//! every substitution already scanned and replaced by a placeholder. Keeping
//! the cutting apart keeps the parent under the file cap.
//! What: [`lift_substitutions`] replaces each live substitution with a
//! [`MARK`] placeholder, [`split_stages`] cuts at the pipeline and list
//! operators, and [`ungroup`] blanks subshell grouping parens.
//! Test: `credential_print_tests` (sibling module).
use super::super::bash_tokens::is_clobber_bar;
use super::super::heredoc::HeredocBodies;
use super::super::shell_lex::QuoteScan;
use super::{Lifted, MARK, Refusal, Sink, Sub, SubKind, scan};
/// Blank unquoted grouping parens: `(gcloud …)` runs `gcloud` in a subshell.
/// #8676: parens inside `${…}` are zsh flags (`${(P)T}`) and stay.
pub(super) fn ungroup(stage: &str) -> String {
let quotes = QuoteScan::new(stage);
if !quotes.balanced {
return stage.to_string();
}
let bytes = stage.as_bytes();
let mut braces = 0usize;
stage
.char_indices()
.map(|(i, c)| {
if c == '{' && i > 0 && bytes[i - 1] == b'$' {
braces += 1;
} else if c == '}' && braces > 0 {
braces -= 1;
}
if matches!(c, '(' | ')') && braces == 0 && quotes.is_unquoted(i) {
' '
} else {
c
}
})
.collect()
}
/// Replace every live substitution in `text` with a [`MARK`] placeholder,
/// scanning each body first and appending it to `lifted.subs`.
///
/// What: the same opener/closer scan as `super::classify_command_substitutions`
/// — `$(…)`/`<(…)`/`>(…)` with paren counting, backtick pairs — on a
/// [`QuoteScan`] of `text`. `$(…)`, backtick and `<(…)` bodies run with a
/// captured stdout; a `>(…)` body writes to the outer stdout. An unclosed
/// opener is refused. `$((` arithmetic ([`opens_arithmetic`]) stays in place
/// (#8676), while a substitution inside it is still lifted. Every slice starts
/// and ends at an ASCII byte.
pub(super) fn lift_substitutions(
text: &str,
stdout: Sink,
stderr: Sink,
depth: usize,
lifted: &mut Lifted,
) -> Result<String, Refusal> {
let scan_quotes = QuoteScan::new(text);
let live = |i: usize| !scan_quotes.balanced || scan_quotes.allows_substitution(i);
let unquoted = |i: usize| !scan_quotes.balanced || scan_quotes.is_unquoted(i);
let bytes = text.as_bytes();
let mut flat = String::with_capacity(text.len());
let (mut i, mut copied) = (0, 0);
while i < bytes.len() {
let arithmetic = opens_arithmetic(bytes, i);
let paren = bytes.get(i + 1) == Some(&b'(')
&& !arithmetic
&& match bytes[i] {
b'$' => live(i),
b'<' | b'>' => unquoted(i),
_ => false,
};
let (body, end) = if paren {
let mut level = 1usize;
let mut j = i + 2;
while j < bytes.len() && level > 0 {
match bytes[j] {
b'(' => level += 1,
b')' => level -= 1,
_ => {}
}
j += 1;
}
if level != 0 {
return Err(Refusal::Unreadable("an unclosed substitution"));
}
(&text[i + 2..j - 1], j)
} else if bytes[i] == b'`' && live(i) {
let close = text[i + 1..]
.find('`')
.ok_or(Refusal::Unreadable("an unclosed backtick"))?;
(&text[i + 1..i + 1 + close], i + close + 2)
} else {
i += 1;
continue;
};
let kind = match (paren, bytes[i]) {
(true, b'<') => SubKind::Input,
(true, b'>') => SubKind::Output,
_ => SubKind::Command,
};
let body_out = if kind == SubKind::Output {
stdout
} else {
Sink::Captured
};
let yields = scan(body, body_out, stderr, depth + 1, lifted)?;
flat.push_str(&text[copied..i]);
flat.push_str(&format!("{MARK}{}__", lifted.subs.len()));
lifted.subs.push(Sub { kind, yields });
i = end;
copied = end;
}
flat.push_str(&text[copied..]);
Ok(flat)
}
/// Whether `$((` at byte `i` opens arithmetic rather than a command
/// substitution whose body starts with a subshell (#8931 critic round).
///
/// What: the shell reads `$((` as arithmetic only when the `(` at `i + 2`
/// closes immediately before the outer `)`; `$((cmd) )` and `$((a); (b))` run
/// commands. An unclosed opener is not arithmetic, so the caller refuses it.
/// A quote, backslash or backtick before the decision point also means "not
/// arithmetic": a quoted `)` would skew the paren count, so the inner group is
/// lifted and scanned instead (#8931).
/// Test: `denies_a_subshell_substitution_shaped_like_arithmetic_8931`.
fn opens_arithmetic(bytes: &[u8], i: usize) -> bool {
if bytes[i] != b'$' || bytes.get(i + 1) != Some(&b'(') || bytes.get(i + 2) != Some(&b'(') {
return false;
}
let mut level = 0usize;
for (j, &b) in bytes.iter().enumerate().skip(i + 2) {
match b {
b'"' | b'\'' | b'\\' | b'`' => return false,
b'(' => level += 1,
b')' => {
level -= 1;
if level == 0 {
return bytes.get(j + 1) == Some(&b')');
}
}
_ => {}
}
}
false
}
/// Split `text` into stages: `(stage, stdout piped on, stderr piped on)`.
///
/// What: cuts at unquoted `|`, `|&`, `||`, `&&`, `;`, newline and a bare `&`
/// (never at a `>&`/`<&`/`&>` redirection), leaving unquoted here-document bodies
/// whole the way `super::split_shell_segments_raw` does.
pub(super) fn split_stages(text: &str) -> Vec<(String, bool, bool)> {
let quotes = QuoteScan::new(text);
let bodies = HeredocBodies::scan(text);
let bytes = text.as_bytes();
let mut stages = Vec::new();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
// #8730: the `|` of `>|`/`>&|` is the redirect's clobber mark, not a pipe.
if (quotes.balanced && !quotes.is_unquoted(i))
|| bodies.suppresses_separator(i)
|| is_clobber_bar(bytes, i)
{
i += 1;
continue;
}
let next = bytes.get(i + 1).copied();
let (width, piped, pipe_stderr) = match (bytes[i], next) {
(b'|', Some(b'|')) | (b'&', Some(b'&')) => (2, false, false),
(b'|', Some(b'&')) => (2, true, true),
(b'|', _) => (1, true, false),
(b';' | b'\n', _) => (1, false, false),
(b'&', _) if i > 0 && matches!(bytes[i - 1], b'>' | b'<') => (0, false, false),
(b'&', Some(b'>')) => (0, false, false),
(b'&', _) => (1, false, false),
_ => (0, false, false),
};
if width == 0 {
i += 1;
continue;
}
stages.push((text[start..i].to_string(), piped, pipe_stderr));
i += width;
start = i;
}
stages.push((text[start..].to_string(), false, false));
stages
}