Krolyxon
Commit 6794220

Branches

Tags

Refs →

remagesrcexec.rs

629 lines · 19.7 KB · Rust

1//! User commands: placeholder expansion, spawning, confirmation and output handling.
2//!
3//! Placeholders describe the current image (`{file} {dir} {name} {stem} {ext} {index} {width}
4//! {height}`) and the targets (`{files} {count}`). In a shell string (`sh -c`) every value is
5//! shell-quoted; in an exec array an element containing `{files}` repeats once per target.
6//! Anything else in braces is left alone, so `awk '{print $1}'` survives.
7
8use std::ffi::{OsStr, OsString};
9use std::io::{BufRead, BufReader, Read, Write};
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::os::unix::process::ExitStatusExt;
12use std::path::{Path, PathBuf};
13use std::process::{ExitStatus, Stdio};
14
15use crate::config::{Command, CommandLine, Output, Stdin, Targets};
16
17/// What a command can see of the viewer when it starts.
18#[derive(Clone, Debug, Default)]
19pub struct Context<'a> {
20 pub current: Option<&'a Path>,
21 /// 0-based position of `current` in the queue.
22 pub index: usize,
23 pub targets: &'a [PathBuf],
24 /// Pixel size of the current image, when known.
25 pub size: Option<[u32; 2]>,
26 pub mode: &'a str,
27 pub socket: Option<&'a Path>,
28}
29
30/// A command ready to spawn.
31#[derive(Clone, Debug, PartialEq)]
32pub struct Prepared {
33 pub program: OsString,
34 pub args: Vec<OsString>,
35 pub env: Vec<(&'static str, OsString)>,
36 pub stdin: Option<Vec<u8>>,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40enum Var {
41 File,
42 Files,
43 Dir,
44 Name,
45 Stem,
46 Ext,
47 Index,
48 Count,
49 Width,
50 Height,
51}
52
53const VARS: &[(&str, Var)] = &[
54 ("file", Var::File),
55 ("files", Var::Files),
56 ("dir", Var::Dir),
57 ("name", Var::Name),
58 ("stem", Var::Stem),
59 ("ext", Var::Ext),
60 ("index", Var::Index),
61 ("count", Var::Count),
62 ("width", Var::Width),
63 ("height", Var::Height),
64];
65
66enum Piece<'t> {
67 Lit(&'t str),
68 Var(Var),
69}
70
71/// Split a template into literal text and known placeholders.
72fn pieces(template: &str) -> Vec<Piece<'_>> {
73 let mut out = Vec::new();
74 let mut rest = template;
75 while let Some(open) = rest.find('{') {
76 let var = rest[open + 1..].find('}').and_then(|close| {
77 let name = &rest[open + 1..open + 1 + close];
78 VARS.iter()
79 .find(|(n, _)| *n == name)
80 .map(|(_, v)| (*v, open + close + 2))
81 });
82 match var {
83 Some((v, end)) => {
84 if open > 0 {
85 out.push(Piece::Lit(&rest[..open]));
86 }
87 out.push(Piece::Var(v));
88 rest = &rest[end..];
89 }
90 None => {
91 out.push(Piece::Lit(&rest[..=open]));
92 rest = &rest[open + 1..];
93 }
94 }
95 }
96 if !rest.is_empty() {
97 out.push(Piece::Lit(rest));
98 }
99 out
100}
101
102fn mentions(template: &str, f: impl Fn(Var) -> bool) -> bool {
103 pieces(template)
104 .iter()
105 .any(|p| matches!(p, Piece::Var(v) if f(*v)))
106}
107
108/// The value of a single-valued placeholder.
109fn value(v: Var, ctx: &Context) -> OsString {
110 let cur = ctx.current;
111 let os = |s: Option<&OsStr>| s.map(OsStr::to_os_string).unwrap_or_default();
112 match v {
113 Var::File => os(cur.map(Path::as_os_str)),
114 Var::Dir => os(cur.and_then(Path::parent).map(Path::as_os_str)),
115 Var::Name => os(cur.and_then(Path::file_name)),
116 Var::Stem => os(cur.and_then(Path::file_stem)),
117 Var::Ext => os(cur.and_then(Path::extension)),
118 Var::Index if cur.is_some() => (ctx.index + 1).to_string().into(),
119 Var::Count => ctx.targets.len().to_string().into(),
120 Var::Width => ctx
121 .size
122 .map(|s| s[0].to_string())
123 .unwrap_or_default()
124 .into(),
125 Var::Height => ctx
126 .size
127 .map(|s| s[1].to_string())
128 .unwrap_or_default()
129 .into(),
130 Var::Index | Var::Files => OsString::new(),
131 }
132}
133
134/// Quote for `sh`: safe words stay bare, everything else goes in single quotes.
135pub fn shell_quote(s: &OsStr) -> Vec<u8> {
136 let b = s.as_bytes();
137 let safe = |c: &u8| c.is_ascii_alphanumeric() || b"_-./,:@%+=".contains(c);
138 if !b.is_empty() && b.iter().all(safe) {
139 return b.to_vec();
140 }
141 let mut out = Vec::with_capacity(b.len() + 2);
142 out.push(b'\'');
143 for &c in b {
144 if c == b'\'' {
145 out.extend_from_slice(b"'\\''");
146 } else {
147 out.push(c);
148 }
149 }
150 out.push(b'\'');
151 out
152}
153
154/// A shell script with every placeholder quoted; `{files}` becomes the quoted targets.
155fn expand_shell(template: &str, ctx: &Context) -> OsString {
156 let mut out = Vec::new();
157 for p in pieces(template) {
158 match p {
159 Piece::Lit(s) => out.extend_from_slice(s.as_bytes()),
160 Piece::Var(Var::Files) => {
161 for (i, f) in ctx.targets.iter().enumerate() {
162 if i > 0 {
163 out.push(b' ');
164 }
165 out.extend(shell_quote(f.as_os_str()));
166 }
167 }
168 Piece::Var(v) => out.extend(shell_quote(&value(v, ctx))),
169 }
170 }
171 OsString::from_vec(out)
172}
173
174/// One exec argument; with `{files}` in it, one argument per target.
175fn expand_arg(template: &str, ctx: &Context) -> Vec<OsString> {
176 let pieces = pieces(template);
177 let build = |file: Option<&Path>| {
178 let mut out = OsString::new();
179 for p in &pieces {
180 match p {
181 Piece::Lit(s) => out.push(s),
182 Piece::Var(Var::Files) => out.push(file.map_or(OsStr::new(""), Path::as_os_str)),
183 Piece::Var(v) => out.push(value(*v, ctx)),
184 }
185 }
186 out
187 };
188 if pieces.iter().any(|p| matches!(p, Piece::Var(Var::Files))) {
189 ctx.targets.iter().map(|f| build(Some(f))).collect()
190 } else {
191 vec![build(None)]
192 }
193}
194
195/// Plain text for the confirm prompt and messages: no quoting, lossy.
196pub fn expand_text(template: &str, ctx: &Context) -> String {
197 let mut out = String::new();
198 for p in pieces(template) {
199 match p {
200 Piece::Lit(s) => out.push_str(s),
201 Piece::Var(Var::Files) => {
202 let names: Vec<_> = ctx
203 .targets
204 .iter()
205 .map(|f| crate::ui::file_name(f))
206 .collect();
207 out.push_str(&names.join(" "));
208 }
209 Piece::Var(v) => out.push_str(&value(v, ctx).to_string_lossy()),
210 }
211 }
212 out
213}
214
215/// `~/…` at the start of an exec element means the home directory.
216fn tilde(s: &str) -> std::borrow::Cow<'_, str> {
217 match (s.strip_prefix("~/"), std::env::var_os("HOME")) {
218 (Some(rest), Some(home)) => format!("{}/{rest}", home.to_string_lossy()).into(),
219 _ => s.into(),
220 }
221}
222
223/// Whether the command needs at least one file to make sense.
224pub fn needs_files(cmd: &Command) -> bool {
225 let files = |v: Var| v != Var::Count;
226 cmd.stdin != Stdin::None
227 || match &cmd.run {
228 CommandLine::Shell(s) => mentions(s, files),
229 CommandLine::Exec(v) => v.iter().any(|a| mentions(a, files)),
230 }
231}
232
233/// The files a command acts on. `marked` is in queue order.
234pub fn targets(
235 t: Targets,
236 current: Option<&Path>,
237 marked: &[&Path],
238 all: &[PathBuf],
239) -> Vec<PathBuf> {
240 let cur = || current.map(Path::to_path_buf).into_iter().collect();
241 let marks = || marked.iter().map(|p| p.to_path_buf()).collect();
242 match t {
243 Targets::Auto if marked.is_empty() => cur(),
244 Targets::Auto | Targets::Marked => marks(),
245 Targets::Current => cur(),
246 Targets::All => all.to_vec(),
247 }
248}
249
250pub fn prepare(cmd: &Command, ctx: &Context) -> Prepared {
251 let (program, args) = match &cmd.run {
252 CommandLine::Shell(s) => (
253 OsString::from("sh"),
254 vec!["-c".into(), expand_shell(s, ctx)],
255 ),
256 CommandLine::Exec(v) => {
257 let mut all = v.iter().flat_map(|a| expand_arg(&tilde(a), ctx));
258 let program = all.next().unwrap_or_default();
259 (program, all.collect())
260 }
261 };
262 let sep = match cmd.stdin {
263 Stdin::None => None,
264 Stdin::Files => Some(b'\n'),
265 Stdin::Files0 => Some(0),
266 };
267 let stdin = sep.map(|sep| {
268 let mut buf = Vec::new();
269 for f in ctx.targets {
270 buf.extend_from_slice(f.as_os_str().as_bytes());
271 buf.push(sep);
272 }
273 buf
274 });
275 let mut env = vec![
276 ("REMAGE_FILE", value(Var::File, ctx)),
277 ("REMAGE_INDEX", value(Var::Index, ctx)),
278 ("REMAGE_COUNT", value(Var::Count, ctx)),
279 ("REMAGE_MODE", ctx.mode.into()),
280 ("REMAGE_PID", std::process::id().to_string().into()),
281 ];
282 if let Some(s) = ctx.socket {
283 env.push(("REMAGE_SOCKET", s.as_os_str().to_os_string()));
284 }
285 Prepared {
286 program,
287 args,
288 env,
289 stdin,
290 }
291}
292
293/// Progress of a running command.
294#[derive(Debug)]
295pub enum Event {
296 /// A stdout line, with `output = "actions"`.
297 Line(String),
298 Done(Outcome),
299}
300
301#[derive(Debug)]
302pub struct Outcome {
303 /// The exit status, or why the command couldn't start.
304 pub status: Result<ExitStatus, String>,
305 /// The last non-empty stdout line, with `output = "status"`.
306 pub last_line: Option<String>,
307 pub first_stderr: Option<String>,
308}
309
310impl Outcome {
311 pub fn success(&self) -> bool {
312 self.status.as_ref().is_ok_and(ExitStatus::success)
313 }
314
315 /// `<code> <first stderr line>` for the status line.
316 pub fn error(&self) -> String {
317 let code = match &self.status {
318 Err(e) => return e.clone(),
319 Ok(s) => match (s.code(), s.signal()) {
320 (Some(c), _) => c.to_string(),
321 (None, Some(sig)) => format!("signal {sig}"),
322 (None, None) => "failed".into(),
323 },
324 };
325 match &self.first_stderr {
326 Some(line) => format!("{code} {line}"),
327 None => code,
328 }
329 }
330}
331
332/// Lines are read lossily and capped so a runaway command can't eat memory.
333const MAX_LINE: usize = 64 << 10;
334
335fn read_line(r: &mut impl BufRead, buf: &mut Vec<u8>) -> Option<String> {
336 buf.clear();
337 match r.by_ref().take(MAX_LINE as u64).read_until(b'\n', buf) {
338 Ok(0) | Err(_) => None,
339 Ok(_) => {
340 if buf.last() != Some(&b'\n') && buf.len() == MAX_LINE {
341 // Skip the rest of an over-long line.
342 let mut sink = Vec::new();
343 let _ = r.read_until(b'\n', &mut sink);
344 }
345 Some(String::from_utf8_lossy(buf).trim_end().to_string())
346 }
347 }
348}
349
350/// Run `p` on its own threads; `sink` gets stdout lines (for `actions`) and then the outcome.
351pub fn spawn(name: &str, p: Prepared, output: Output, sink: impl Fn(Event) + Send + 'static) {
352 let label = name.to_string();
353 let run = move || {
354 let mut cmd = std::process::Command::new(&p.program);
355 cmd.args(&p.args)
356 .envs(p.env.iter().map(|(k, v)| (k, v)))
357 .stdin(if p.stdin.is_some() {
358 Stdio::piped()
359 } else {
360 Stdio::null()
361 })
362 .stdout(if output == Output::Ignore {
363 Stdio::null()
364 } else {
365 Stdio::piped()
366 })
367 .stderr(Stdio::piped());
368 let mut child = match cmd.spawn() {
369 Ok(c) => c,
370 Err(e) => {
371 let program = p.program.to_string_lossy();
372 return sink(Event::Done(Outcome {
373 status: Err(format!("{program}: {e}")),
374 last_line: None,
375 first_stderr: None,
376 }));
377 }
378 };
379 log::debug!("command {label}: pid {}", child.id());
380 if let (Some(mut w), Some(data)) = (child.stdin.take(), p.stdin) {
381 // Its own thread: a child that writes before reading must not deadlock us.
382 std::thread::spawn(move || {
383 let _ = w.write_all(&data);
384 });
385 }
386 let stderr = child.stderr.take().map(|e| {
387 std::thread::spawn(move || {
388 let mut r = BufReader::new(e);
389 let mut buf = Vec::new();
390 let mut first = None;
391 while let Some(line) = read_line(&mut r, &mut buf) {
392 if first.is_none() && !line.trim().is_empty() {
393 first = Some(line.trim().to_string());
394 }
395 }
396 first
397 })
398 });
399 let mut last_line = None;
400 if let Some(out) = child.stdout.take() {
401 let mut r = BufReader::new(out);
402 let mut buf = Vec::new();
403 while let Some(line) = read_line(&mut r, &mut buf) {
404 if line.trim().is_empty() {
405 continue;
406 }
407 match output {
408 Output::Actions => sink(Event::Line(line)),
409 _ => last_line = Some(line.trim().to_string()),
410 }
411 }
412 }
413 let status = child.wait().map_err(|e| e.to_string());
414 let first_stderr = stderr.and_then(|t| t.join().ok()).flatten();
415 sink(Event::Done(Outcome {
416 status,
417 last_line,
418 first_stderr,
419 }));
420 };
421 if let Err(e) = std::thread::Builder::new()
422 .name("remage-command".into())
423 .spawn(run)
424 {
425 log::error!("command {name}: {e}");
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::config::ActionList;
433
434 fn cmd(run: CommandLine) -> Command {
435 Command {
436 desc: String::new(),
437 run,
438 targets: Targets::Auto,
439 stdin: Stdin::None,
440 confirm: None,
441 output: Output::Status,
442 after: ActionList::default(),
443 }
444 }
445
446 fn ctx<'a>(targets: &'a [PathBuf]) -> Context<'a> {
447 Context {
448 current: Some(Path::new("/pix/it's a.png")),
449 index: 4,
450 targets,
451 size: Some([640, 480]),
452 mode: "image",
453 socket: Some(Path::new("/run/remage/1.sock")),
454 }
455 }
456
457 fn files() -> Vec<PathBuf> {
458 vec!["/pix/a b.png".into(), "/pix/c.jpg".into()]
459 }
460
461 #[test]
462 fn quoting() {
463 let q = |s: &str| String::from_utf8(shell_quote(OsStr::new(s))).unwrap();
464 assert_eq!(q("/a/b-c_d.png"), "/a/b-c_d.png");
465 assert_eq!(q("a b"), "'a b'");
466 assert_eq!(q("it's"), "'it'\\''s'");
467 assert_eq!(q(""), "''");
468 assert_eq!(q("$(rm -rf ~)"), "'$(rm -rf ~)'");
469 }
470
471 #[test]
472 fn shell_strings_quote_every_placeholder() {
473 let f = files();
474 let p = prepare(
475 &cmd(CommandLine::Shell(
476 "cp {files} {dir} && echo {name} {stem}.{ext} {index}/{count} {width}x{height} \
477 | awk '{print $1}' {nope}"
478 .into(),
479 )),
480 &ctx(&f),
481 );
482 assert_eq!(p.program, "sh");
483 assert_eq!(
484 p.args[1],
485 "cp '/pix/a b.png' /pix/c.jpg /pix && echo 'it'\\''s a.png' 'it'\\''s a'.png 5/2 \
486 640x480 | awk '{print $1}' {nope}"
487 );
488 }
489
490 #[test]
491 fn exec_arrays_expand_files_per_target() {
492 let f = files();
493 let p = prepare(
494 &cmd(CommandLine::Exec(vec![
495 "rm".into(),
496 "--".into(),
497 "{files}".into(),
498 "--x={files}".into(),
499 "{file}".into(),
500 ])),
501 &ctx(&f),
502 );
503 assert_eq!(p.program, "rm");
504 let args: Vec<_> = p.args.iter().map(|a| a.to_str().unwrap()).collect();
505 assert_eq!(
506 args,
507 [
508 "--",
509 "/pix/a b.png",
510 "/pix/c.jpg",
511 "--x=/pix/a b.png",
512 "--x=/pix/c.jpg",
513 "/pix/it's a.png"
514 ]
515 );
516 // `~/` expands in the template, never in file names.
517 let p = prepare(
518 &cmd(CommandLine::Exec(vec!["~/bin/x".into(), "{file}".into()])),
519 &Context {
520 current: Some(Path::new("~/a.png")),
521 ..ctx(&f)
522 },
523 );
524 assert!(!p.program.to_string_lossy().starts_with('~'));
525 assert_eq!(p.args[0], "~/a.png");
526 }
527
528 #[test]
529 fn stdin_and_env() {
530 let f = files();
531 let mut c = cmd(CommandLine::Exec(vec!["handler".into()]));
532 assert!(prepare(&c, &ctx(&f)).stdin.is_none());
533 assert!(!needs_files(&c));
534 c.stdin = Stdin::Files0;
535 let p = prepare(&c, &ctx(&f));
536 assert_eq!(p.stdin.as_deref(), Some(&b"/pix/a b.png\0/pix/c.jpg\0"[..]));
537 assert!(needs_files(&c));
538 let env: std::collections::HashMap<_, _> = p.env.into_iter().collect();
539 assert_eq!(env["REMAGE_FILE"], "/pix/it's a.png");
540 assert_eq!(env["REMAGE_INDEX"], "5");
541 assert_eq!(env["REMAGE_COUNT"], "2");
542 assert_eq!(env["REMAGE_MODE"], "image");
543 assert_eq!(env["REMAGE_SOCKET"], "/run/remage/1.sock");
544 assert!(needs_files(&cmd(CommandLine::Shell("echo {name}".into()))));
545 assert!(!needs_files(&cmd(CommandLine::Shell(
546 "echo {count}".into()
547 ))));
548 }
549
550 #[test]
551 fn confirm_text() {
552 let f = files();
553 assert_eq!(
554 expand_text("Delete {count} file(s)? {files}", &ctx(&f)),
555 "Delete 2 file(s)? a b.png c.jpg"
556 );
557 }
558
559 #[test]
560 fn target_sets() {
561 let all = files();
562 let cur = Some(Path::new("/pix/c.jpg"));
563 let none: [&Path; 0] = [];
564 let marked = [Path::new("/pix/a b.png")];
565 assert_eq!(targets(Targets::Auto, cur, &none, &all), [all[1].clone()]);
566 assert_eq!(targets(Targets::Auto, cur, &marked, &all), [all[0].clone()]);
567 assert_eq!(
568 targets(Targets::Current, cur, &marked, &all),
569 [all[1].clone()]
570 );
571 assert!(targets(Targets::Marked, cur, &none, &all).is_empty());
572 assert_eq!(targets(Targets::All, None, &none, &all), all);
573 }
574
575 fn run(run: CommandLine, output: Output, stdin: Option<&[u8]>) -> (Vec<String>, Outcome) {
576 let (tx, rx) = crossbeam_channel::unbounded();
577 let mut p = prepare(&cmd(run), &ctx(&[]));
578 p.stdin = stdin.map(<[u8]>::to_vec);
579 spawn("test", p, output, move |e| {
580 let _ = tx.send(e);
581 });
582 let mut lines = Vec::new();
583 loop {
584 match rx.recv_timeout(std::time::Duration::from_secs(10)) {
585 Ok(Event::Line(l)) => lines.push(l),
586 Ok(Event::Done(o)) => return (lines, o),
587 Err(e) => panic!("{e}"),
588 }
589 }
590 }
591
592 #[test]
593 fn spawning_reports_output_and_errors() {
594 let (_, o) = run(
595 CommandLine::Shell("echo one; echo; echo two; echo $REMAGE_MODE".into()),
596 Output::Status,
597 None,
598 );
599 assert!(o.success());
600 assert_eq!(o.last_line.as_deref(), Some("image"));
601
602 let (lines, o) = run(
603 CommandLine::Shell("cat; echo next".into()),
604 Output::Actions,
605 Some(b"goto 3\n"),
606 );
607 assert!(o.success() && o.last_line.is_none());
608 assert_eq!(lines, ["goto 3", "next"]);
609
610 let (_, o) = run(
611 CommandLine::Shell("echo oops >&2; echo more >&2; exit 3".into()),
612 Output::Ignore,
613 None,
614 );
615 assert!(!o.success());
616 assert_eq!(o.error(), "3 oops");
617
618 let (_, o) = run(
619 CommandLine::Exec(vec!["/nonexistent/remage-test".into()]),
620 Output::Status,
621 None,
622 );
623 assert!(
624 o.error().starts_with("/nonexistent/remage-test: "),
625 "{}",
626 o.error()
627 );
628 }
629}