Skip to content

Commit d00f741

Browse files
committed
feat(phase-4): harden find --exec execution path
1 parent 2d89c78 commit d00f741

4 files changed

Lines changed: 96 additions & 31 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ futures = "0.3"
6161
async-trait = "0.1"
6262
mime_guess = "2.0"
6363
glob = "0.3"
64+
shlex = "1.3"
6465

6566
# HTTP client for Admin API
6667
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "rustls-tls-webpki-roots", "json"] }

crates/cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ jiff.workspace = true
4949
humansize.workspace = true
5050
mime_guess.workspace = true
5151
glob.workspace = true
52+
shlex.workspace = true
5253

5354
[features]
5455
default = []
@@ -60,4 +61,3 @@ golden = []
6061
[dev-dependencies]
6162
tempfile.workspace = true
6263
insta.workspace = true
63-

crates/cli/src/commands/find.rs

Lines changed: 93 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use rc_core::{AliasManager, ListOptions, ObjectStore as _, RemotePath};
77
use rc_s3::S3Client;
88
use serde::Serialize;
99
use std::io::Write as _;
10-
use std::process::Command;
10+
use std::process::{Command, Output};
1111

1212
use crate::exit_code::ExitCode;
1313
use crate::output::{Formatter, OutputConfig};
@@ -140,13 +140,25 @@ pub async fn execute(args: FindArgs, output_config: OutputConfig) -> ExitCode {
140140
return ExitCode::UsageError;
141141
}
142142

143+
let exec_argv_template = match parse_exec_template(exec_template) {
144+
Ok(template) => template,
145+
Err(e) => {
146+
formatter.error(&e);
147+
return ExitCode::UsageError;
148+
}
149+
};
150+
143151
for m in &matches {
144152
let object_path = full_object_path(&alias_name, &bucket, &m.key);
145-
let command_text = exec_template.replace("{}", &object_path);
146-
let output = match run_shell_command(&command_text) {
153+
let (program, exec_args, command_text) =
154+
render_exec_command(&exec_argv_template, &object_path);
155+
let output = match run_exec_command(&program, &exec_args) {
147156
Ok(output) => output,
148157
Err(e) => {
149-
formatter.error(&format!("Failed to run command for {}: {}", object_path, e));
158+
formatter.error(&format!(
159+
"Failed to run command for {}: {} ({})",
160+
object_path, command_text, e
161+
));
150162
return ExitCode::GeneralError;
151163
}
152164
};
@@ -162,31 +174,24 @@ pub async fn execute(args: FindArgs, output_config: OutputConfig) -> ExitCode {
162174

163175
if !output.status.success() {
164176
formatter.error(&format!(
165-
"Command failed for {}: {}",
166-
object_path, command_text
177+
"Command failed for {} (status {}): {}",
178+
object_path, output.status, command_text
167179
));
168180
return ExitCode::GeneralError;
169181
}
170182
}
171183
}
172184

173-
let display_matches: Vec<MatchInfo> = matches
174-
.iter()
175-
.map(|m| MatchInfo {
176-
key: if args.print {
177-
full_object_path(&alias_name, &bucket, &m.key)
178-
} else {
179-
m.key.clone()
180-
},
181-
size_bytes: m.size_bytes,
182-
size_human: m.size_human.clone(),
183-
last_modified: m.last_modified.clone(),
184-
})
185-
.collect();
185+
let mut display_matches = matches;
186+
if args.print {
187+
for m in &mut display_matches {
188+
m.key = full_object_path(&alias_name, &bucket, &m.key);
189+
}
190+
}
186191

187192
// Calculate totals
188-
let total_count = matches.len();
189-
let total_size: i64 = matches.iter().filter_map(|m| m.size_bytes).sum();
193+
let total_count = display_matches.len();
194+
let total_size: i64 = display_matches.iter().filter_map(|m| m.size_bytes).sum();
190195

191196
if args.count {
192197
// Only print count
@@ -234,18 +239,35 @@ pub async fn execute(args: FindArgs, output_config: OutputConfig) -> ExitCode {
234239
}
235240

236241
fn full_object_path(alias: &str, bucket: &str, key: &str) -> String {
237-
format!("{alias}/{bucket}/{key}")
242+
RemotePath::new(alias, bucket, key).to_full_path()
238243
}
239244

240-
fn run_shell_command(command: &str) -> std::io::Result<std::process::Output> {
241-
#[cfg(target_family = "windows")]
242-
{
243-
Command::new("cmd").args(["/C", command]).output()
244-
}
245-
#[cfg(not(target_family = "windows"))]
246-
{
247-
Command::new("sh").args(["-c", command]).output()
245+
fn parse_exec_template(exec_template: &str) -> Result<Vec<String>, String> {
246+
let args = shlex::split(exec_template)
247+
.ok_or_else(|| "Invalid --exec template: unbalanced quotes".to_string())?;
248+
if args.is_empty() {
249+
return Err("Invalid --exec template: command cannot be empty".to_string());
248250
}
251+
252+
Ok(args)
253+
}
254+
255+
fn render_exec_command(
256+
argv_template: &[String],
257+
object_path: &str,
258+
) -> (String, Vec<String>, String) {
259+
let rendered: Vec<String> = argv_template
260+
.iter()
261+
.map(|arg| arg.replace("{}", object_path))
262+
.collect();
263+
let program = rendered[0].clone();
264+
let args = rendered[1..].to_vec();
265+
let command_text = rendered.join(" ");
266+
(program, args, command_text)
267+
}
268+
269+
fn run_exec_command(program: &str, args: &[String]) -> std::io::Result<Output> {
270+
Command::new(program).args(args).output()
249271
}
250272

251273
/// Filters for find command
@@ -507,5 +529,46 @@ mod tests {
507529
full_object_path("test", "bucket", "a/b.txt"),
508530
"test/bucket/a/b.txt"
509531
);
532+
assert_eq!(full_object_path("test", "bucket", ""), "test/bucket");
533+
}
534+
535+
#[test]
536+
fn test_parse_exec_template() {
537+
assert_eq!(
538+
parse_exec_template("echo EXEC:{}").unwrap(),
539+
vec!["echo".to_string(), "EXEC:{}".to_string()]
540+
);
541+
assert_eq!(
542+
parse_exec_template(r#"printf '%s\n' "{}""#).unwrap(),
543+
vec!["printf".to_string(), "%s\\n".to_string(), "{}".to_string()]
544+
);
545+
}
546+
547+
#[test]
548+
fn test_parse_exec_template_errors() {
549+
assert!(parse_exec_template("").is_err());
550+
assert!(parse_exec_template("'unterminated").is_err());
551+
}
552+
553+
#[test]
554+
fn test_render_exec_command() {
555+
let template = vec![
556+
"echo".to_string(),
557+
"prefix:{}".to_string(),
558+
"{}".to_string(),
559+
];
560+
let (program, args, text) = render_exec_command(&template, "test/bucket/a.txt");
561+
assert_eq!(program, "echo");
562+
assert_eq!(
563+
args,
564+
vec![
565+
"prefix:test/bucket/a.txt".to_string(),
566+
"test/bucket/a.txt".to_string()
567+
]
568+
);
569+
assert_eq!(
570+
text,
571+
"echo prefix:test/bucket/a.txt test/bucket/a.txt".to_string()
572+
);
510573
}
511574
}

0 commit comments

Comments
 (0)