Skip to content

Commit 455b93b

Browse files
committed
fix(plan): reject compound shell cd before array continuations
1 parent fc17f50 commit 455b93b

7 files changed

Lines changed: 170 additions & 38 deletions

File tree

crates/vite_shell/src/lib.rs

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use brush_parser::{
44
Parser, ParserOptions,
55
ast::{
66
AndOr, Assignment, AssignmentName, AssignmentValue, Command, CommandPrefix,
7-
CommandPrefixOrSuffixItem, CommandSuffix, CompoundListItem, Pipeline, Program,
8-
SeparatorOperator, SimpleCommand, SourceLocation, Word,
7+
CommandPrefixOrSuffixItem, CommandSuffix, CompoundCommand, CompoundList, CompoundListItem,
8+
DoGroupCommand, Pipeline, Program, SeparatorOperator, SimpleCommand, SourceLocation, Word,
99
},
1010
word::{WordPiece, WordPieceWithSource},
1111
};
@@ -135,43 +135,115 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range<
135135
Some((TaskParsedCommand { envs, program: unquote(program)?, args }, range))
136136
}
137137

138-
fn pipeline_program_is_cd(pipeline: &Pipeline) -> bool {
139-
let Pipeline { timed: None, bang: false, seq } = pipeline else {
138+
fn command_name_may_change_cwd(command_name: &str) -> bool {
139+
matches!(command_name, "cd" | "chdir" | "." | "source" | "eval")
140+
}
141+
142+
fn wrapper_command_may_change_cwd(suffix: Option<&CommandSuffix>) -> bool {
143+
let Some(CommandSuffix(suffix_items)) = suffix else {
140144
return false;
141145
};
142-
let [Command::Simple(simple_command)] = seq.as_slice() else {
146+
147+
for suffix_item in suffix_items {
148+
let CommandPrefixOrSuffixItem::Word(word) = suffix_item else {
149+
continue;
150+
};
151+
let Some(arg) = unquote(word) else {
152+
return true;
153+
};
154+
if arg == "--" || arg.starts_with('-') {
155+
continue;
156+
}
157+
return command_name_may_change_cwd(arg.as_str());
158+
}
159+
160+
false
161+
}
162+
163+
fn simple_command_may_change_cwd(simple_command: &SimpleCommand) -> bool {
164+
let Some(program) = &simple_command.word_or_name else {
143165
return false;
144166
};
145-
let SimpleCommand { word_or_name: Some(program), .. } = simple_command else {
146-
return false;
167+
let Some(program) = unquote(program) else {
168+
return true;
147169
};
148-
unquote(program).is_some_and(|program| program.as_str() == "cd")
170+
171+
match program.as_str() {
172+
"command" | "builtin" | "time" => {
173+
wrapper_command_may_change_cwd(simple_command.suffix.as_ref())
174+
}
175+
command_name => command_name_may_change_cwd(command_name),
176+
}
177+
}
178+
179+
fn and_or_list_may_change_cwd(and_or_list: &brush_parser::ast::AndOrList) -> bool {
180+
pipeline_may_change_cwd(&and_or_list.first)
181+
|| and_or_list.additional.iter().any(|and_or| {
182+
let pipeline = match and_or {
183+
AndOr::And(pipeline) | AndOr::Or(pipeline) => pipeline,
184+
};
185+
pipeline_may_change_cwd(pipeline)
186+
})
187+
}
188+
189+
fn compound_list_may_change_cwd(compound_list: &CompoundList) -> bool {
190+
compound_list
191+
.0
192+
.iter()
193+
.any(|CompoundListItem(and_or_list, _)| and_or_list_may_change_cwd(and_or_list))
194+
}
195+
196+
fn do_group_may_change_cwd(do_group: &DoGroupCommand) -> bool {
197+
compound_list_may_change_cwd(&do_group.list)
198+
}
199+
200+
fn compound_command_may_change_cwd(compound_command: &CompoundCommand) -> bool {
201+
match compound_command {
202+
CompoundCommand::Arithmetic(_) | CompoundCommand::Subshell(_) => false,
203+
CompoundCommand::ArithmeticForClause(command) => do_group_may_change_cwd(&command.body),
204+
CompoundCommand::BraceGroup(command) => compound_list_may_change_cwd(&command.list),
205+
CompoundCommand::ForClause(command) => do_group_may_change_cwd(&command.body),
206+
CompoundCommand::CaseClause(command) => command
207+
.cases
208+
.iter()
209+
.any(|case| case.cmd.as_ref().is_some_and(compound_list_may_change_cwd)),
210+
CompoundCommand::IfClause(command) => {
211+
compound_list_may_change_cwd(&command.condition)
212+
|| compound_list_may_change_cwd(&command.then)
213+
|| command.elses.as_ref().is_some_and(|elses| {
214+
elses.iter().any(|else_clause| {
215+
else_clause.condition.as_ref().is_some_and(compound_list_may_change_cwd)
216+
|| compound_list_may_change_cwd(&else_clause.body)
217+
})
218+
})
219+
}
220+
CompoundCommand::WhileClause(command) | CompoundCommand::UntilClause(command) => {
221+
compound_list_may_change_cwd(&command.0) || do_group_may_change_cwd(&command.1)
222+
}
223+
}
224+
}
225+
226+
fn command_may_change_cwd(command: &Command) -> bool {
227+
match command {
228+
Command::Simple(simple_command) => simple_command_may_change_cwd(simple_command),
229+
Command::Compound(compound_command, _) => compound_command_may_change_cwd(compound_command),
230+
Command::Function(function) => compound_command_may_change_cwd(&function.body.0),
231+
Command::ExtendedTest(..) => false,
232+
}
233+
}
234+
235+
fn pipeline_may_change_cwd(pipeline: &Pipeline) -> bool {
236+
pipeline.seq.iter().any(command_may_change_cwd)
149237
}
150238

151239
#[must_use]
152-
pub fn contains_cd_command(cmd: &str) -> bool {
240+
pub fn shell_command_may_change_cwd(cmd: &str) -> bool {
153241
let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS);
154242
let Ok(Program { complete_commands }) = parser.parse_program() else {
155-
return false;
243+
return true;
156244
};
157245

158-
for compound_list in &complete_commands {
159-
for CompoundListItem(and_or_list, _) in &compound_list.0 {
160-
if pipeline_program_is_cd(&and_or_list.first) {
161-
return true;
162-
}
163-
for and_or in &and_or_list.additional {
164-
let pipeline = match and_or {
165-
AndOr::And(pipeline) | AndOr::Or(pipeline) => pipeline,
166-
};
167-
if pipeline_program_is_cd(pipeline) {
168-
return true;
169-
}
170-
}
171-
}
172-
}
173-
174-
false
246+
complete_commands.iter().any(compound_list_may_change_cwd)
175247
}
176248

177249
#[must_use]
@@ -202,16 +274,26 @@ mod tests {
202274
use super::*;
203275

204276
#[test]
205-
fn test_contains_cd_command_with_unresolved_arg() {
206-
assert!(contains_cd_command(r#"cd "$APP_DIR""#));
207-
assert!(contains_cd_command(r#"echo ok && cd "$APP_DIR""#));
208-
assert!(contains_cd_command(r#"FOO=bar 'cd' "$APP_DIR""#));
277+
fn test_shell_command_may_change_cwd_with_unresolved_arg() {
278+
assert!(shell_command_may_change_cwd(r#"cd "$APP_DIR""#));
279+
assert!(shell_command_may_change_cwd(r#"echo ok && cd "$APP_DIR""#));
280+
assert!(shell_command_may_change_cwd(r#"FOO=bar 'cd' "$APP_DIR""#));
281+
}
282+
283+
#[test]
284+
fn test_shell_command_may_change_cwd_in_compound_commands() {
285+
assert!(shell_command_may_change_cwd("if true; then cd snapshots; fi"));
286+
assert!(shell_command_may_change_cwd("{ cd snapshots; }"));
287+
assert!(shell_command_may_change_cwd("f(){ cd snapshots; }; f"));
288+
assert!(shell_command_may_change_cwd("command cd snapshots"));
289+
assert!(shell_command_may_change_cwd("time cd snapshots"));
209290
}
210291

211292
#[test]
212-
fn test_contains_cd_command_ignores_cd_argument_text() {
213-
assert!(!contains_cd_command(r#"echo "cd $APP_DIR""#));
214-
assert!(!contains_cd_command("cdtool $APP_DIR"));
293+
fn test_shell_command_may_change_cwd_ignores_non_current_shell_cd() {
294+
assert!(!shell_command_may_change_cwd(r#"echo "cd $APP_DIR""#));
295+
assert!(!shell_command_may_change_cwd("cdtool $APP_DIR"));
296+
assert!(!shell_command_may_change_cwd("(cd snapshots)"));
215297
}
216298

217299
#[test]

crates/vite_task_plan/src/plan.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use futures_util::FutureExt;
1616
use petgraph::Direction;
1717
use rustc_hash::FxHashMap;
1818
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf, relative::InvalidPathDataError};
19-
use vite_shell::{TaskParsedCommand, contains_cd_command, try_parse_as_and_list};
19+
use vite_shell::{TaskParsedCommand, shell_command_may_change_cwd, try_parse_as_and_list};
2020
use vite_str::Str;
2121
use vite_task_graph::{
2222
TaskNodeIndex, TaskSource,
@@ -116,11 +116,13 @@ fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error>
116116
});
117117
}
118118
} else {
119+
// A shell fallback runs in a child shell, so any cwd change inside it cannot be
120+
// reflected in the planner's cwd for following array entries.
119121
if array_len.is_some_and(|len| snippet_index + 1 < len)
120-
&& contains_cd_command(snippet.as_str())
122+
&& shell_command_may_change_cwd(snippet.as_str())
121123
{
122124
return Err(Error::InvalidTaskCommand(
123-
"command array entries that change directory in a shell must be the final entry"
125+
"command array entries that may change directory in a shell must be the final entry"
124126
.into(),
125127
));
126128
}

crates/vite_task_plan/tests/plan_snapshots/fixtures/task_command_shorthands/snapshots.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ args = ["run", "array_cd_shell"]
2626
name = "array_shell_cd_before_next_error"
2727
args = ["run", "array_shell_cd_before_next"]
2828

29+
[[plan]]
30+
name = "array_compound_cd_before_next_error"
31+
args = ["run", "array_compound_cd_before_next"]
32+
2933
[[plan]]
3034
name = "object_array_cache_false"
3135
args = ["run", "object_array_cache_false"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Invalid task command: command array entries that may change directory in a shell must be the final entry
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Invalid task command: command array entries that change directory in a shell must be the final entry
1+
Invalid task command: command array entries that may change directory in a shell must be the final entry

crates/vite_task_plan/tests/plan_snapshots/fixtures/task_command_shorthands/snapshots/task_graph.jsonc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,48 @@
8484
},
8585
"neighbors": []
8686
},
87+
{
88+
"key": [
89+
"<workspace>/",
90+
"array_compound_cd_before_next"
91+
],
92+
"node": {
93+
"task_display": {
94+
"package_name": "@test/task-command-shorthands",
95+
"task_name": "array_compound_cd_before_next",
96+
"package_path": "<workspace>/"
97+
},
98+
"resolved_config": {
99+
"command": [
100+
"if true; then cd snapshots; fi",
101+
"vtt print-file package.json"
102+
],
103+
"resolved_options": {
104+
"cwd": "<workspace>/",
105+
"cache_config": {
106+
"env_config": {
107+
"fingerprinted_envs": [],
108+
"untracked_env": [
109+
"<default untracked envs>"
110+
]
111+
},
112+
"input_config": {
113+
"includes_auto": true,
114+
"positive_globs": [],
115+
"negative_globs": []
116+
},
117+
"output_config": {
118+
"includes_auto": false,
119+
"positive_globs": [],
120+
"negative_globs": []
121+
}
122+
}
123+
}
124+
},
125+
"source": "TaskConfig"
126+
},
127+
"neighbors": []
128+
},
87129
{
88130
"key": [
89131
"<workspace>/",

crates/vite_task_plan/tests/plan_snapshots/fixtures/task_command_shorthands/vite-task.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"array_cd_spawn": ["cd snapshots", "vtt print-file package.json"],
88
"array_cd_shell": ["cd snapshots", "echo $PWD"],
99
"array_shell_cd_before_next": ["cd \"$APP_DIR\"", "vtt print-file package.json"],
10+
"array_compound_cd_before_next": ["if true; then cd snapshots; fi", "vtt print-file package.json"],
1011
"object_array_cache_false": {
1112
"command": ["vtt print-file package.json", "vtt print-file vite-task.json", "vtt print-file package.json"],
1213
"cache": false

0 commit comments

Comments
 (0)