diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index c2497e1bf1..5d66941b96 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -32,9 +32,10 @@ USAGE: only Gray and CMYK shrink) or progressive. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] Open a file, run engine commands in order, save the result. Each --params - applies to the preceding --cmd. Prints each command's JSON result. + applies to the preceding --cmd. Prints each command's JSON result. An --out that is the + is refused, as the result would replace the original; --in-place allows it. photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression ] [--dds-mipmaps] [--jpeg-format ] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, @@ -99,7 +100,7 @@ const SUBCOMMANDS: &[Subcommand] = &[ "--tiff-compression", "--jpeg-format", ], - bare: &["--tiff-layers", "--dds-mipmaps"], + bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"], run: run_cmds, }, Subcommand { @@ -373,6 +374,10 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let mut reported_fonts = std::collections::HashSet::::new(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { + // Refused before the file is read or any command runs. + if let Some(out) = a.get("--out") { + refuse_overwriting_input(a, file, out)?; + } let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); reported_fonts.extend(ws.iter().cloned()); diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index c31239d14f..95e46a7b45 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -563,6 +563,46 @@ fn tga_bits_choose_the_targa_depth() { } } +/// #7437: `run --out` to that same file, however `--out` is spelt, is refused before the +/// file is read or any command runs, unless `--in-place` asks for it (as `batch` does, #492). +#[test] +fn run_refuses_to_write_over_its_input() { + let d = tmp("run-in-place"); + write_png(&d.join("in.png"), 8, 4, 7); + let before = std::fs::read(d.join("in.png")).unwrap(); + let run = |out: &Path, extra: &[&str]| { + bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out"]).arg(out).args(extra).output().unwrap() + }; + let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")]; + std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap(); + outs.push(PathBuf::from("hard.png")); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap(); + outs.push(PathBuf::from("soft.png")); + } + for out in &outs { + for extra in [&[][..], &["--format", "png"][..]] { + let o = run(out, extra); + let (stdout, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display()); + assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}"); + assert!(stdout.is_empty(), "--out {} {extra:?} ran commands: {stdout}", out.display()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "--out {} {extra:?} changed the original", out.display()); + } + } + // Another file is written as before, leaving the original alone. + ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "out.png"])); + assert!(d.join("out.png").is_file()); + assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before); + // `--in-place` opts in: the inverted result replaces the original, which still opens. + ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"])); + assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged"); + let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])); + let info: Value = serde_json::from_str(&out).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}"); +} + /// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it /// doesn't name is refused and nothing is written. #[test]