Skip to content

Commit fc17f50

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

6 files changed

Lines changed: 112 additions & 2 deletions

File tree

crates/vite_shell/src/lib.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,45 @@ 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 {
140+
return false;
141+
};
142+
let [Command::Simple(simple_command)] = seq.as_slice() else {
143+
return false;
144+
};
145+
let SimpleCommand { word_or_name: Some(program), .. } = simple_command else {
146+
return false;
147+
};
148+
unquote(program).is_some_and(|program| program.as_str() == "cd")
149+
}
150+
151+
#[must_use]
152+
pub fn contains_cd_command(cmd: &str) -> bool {
153+
let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS);
154+
let Ok(Program { complete_commands }) = parser.parse_program() else {
155+
return false;
156+
};
157+
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
175+
}
176+
138177
#[must_use]
139178
pub fn try_parse_as_and_list(cmd: &str) -> Option<Vec<(TaskParsedCommand, Range<usize>)>> {
140179
let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS);
@@ -162,6 +201,19 @@ pub fn try_parse_as_and_list(cmd: &str) -> Option<Vec<(TaskParsedCommand, Range<
162201
mod tests {
163202
use super::*;
164203

204+
#[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""#));
209+
}
210+
211+
#[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"));
215+
}
216+
165217
#[test]
166218
fn test_parse_single_command() {
167219
let source = r"A=B hello world";

crates/vite_task_plan/src/plan.rs

Lines changed: 12 additions & 2 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, try_parse_as_and_list};
19+
use vite_shell::{TaskParsedCommand, contains_cd_command, try_parse_as_and_list};
2020
use vite_str::Str;
2121
use vite_task_graph::{
2222
TaskNodeIndex, TaskSource,
@@ -88,6 +88,7 @@ enum PlannedCommand {
8888

8989
#[expect(clippy::result_large_err, reason = "Error is large for diagnostics")]
9090
fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error> {
91+
let mut array_len = None;
9192
let snippets: Box<dyn Iterator<Item = &Str> + '_> = match command {
9293
TaskCommand::String(command) => Box::new(std::iter::once(command)),
9394
TaskCommand::Array(commands) => {
@@ -99,12 +100,13 @@ fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error>
99100
"command array entries must not be empty".into(),
100101
));
101102
}
103+
array_len = Some(commands.len());
102104
Box::new(commands.iter())
103105
}
104106
};
105107

106108
let mut planned = Vec::new();
107-
for snippet in snippets {
109+
for (snippet_index, snippet) in snippets.enumerate() {
108110
if let Some(parsed) = try_parse_as_and_list(snippet.as_str()) {
109111
for (and_item, range) in parsed {
110112
planned.push(PlannedCommand::Parsed {
@@ -114,6 +116,14 @@ fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error>
114116
});
115117
}
116118
} else {
119+
if array_len.is_some_and(|len| snippet_index + 1 < len)
120+
&& contains_cd_command(snippet.as_str())
121+
{
122+
return Err(Error::InvalidTaskCommand(
123+
"command array entries that change directory in a shell must be the final entry"
124+
.into(),
125+
));
126+
}
117127
planned.push(PlannedCommand::Shell(snippet.clone()));
118128
}
119129
}

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
@@ -22,6 +22,10 @@ args = ["run", "array_cd_spawn"]
2222
name = "array_cd_shell"
2323
args = ["run", "array_cd_shell"]
2424

25+
[[plan]]
26+
name = "array_shell_cd_before_next_error"
27+
args = ["run", "array_shell_cd_before_next"]
28+
2529
[[plan]]
2630
name = "object_array_cache_false"
2731
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 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_shell_cd_before_next"
91+
],
92+
"node": {
93+
"task_display": {
94+
"package_name": "@test/task-command-shorthands",
95+
"task_name": "array_shell_cd_before_next",
96+
"package_path": "<workspace>/"
97+
},
98+
"resolved_config": {
99+
"command": [
100+
"cd \"$APP_DIR\"",
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
@@ -6,6 +6,7 @@
66
"nested_vt_array": ["vtt print-file package.json", "vt run string_shorthand"],
77
"array_cd_spawn": ["cd snapshots", "vtt print-file package.json"],
88
"array_cd_shell": ["cd snapshots", "echo $PWD"],
9+
"array_shell_cd_before_next": ["cd \"$APP_DIR\"", "vtt print-file package.json"],
910
"object_array_cache_false": {
1011
"command": ["vtt print-file package.json", "vtt print-file vite-task.json", "vtt print-file package.json"],
1112
"cache": false

0 commit comments

Comments
 (0)