use crate::{MediaType, RawArtifact, TransformRequest, WatermarkInput, sniff_artifact, transform};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::core::error_class::ErrorClass;
use super::{
ClapConvertArgs, ClapOptimizeArgs, CliError, Command, ConvertCommand, EXIT_IO, EXIT_RUNTIME,
EXIT_USAGE, HelpTopic, InputSource, MAX_REMOTE_WATERMARK_BYTES, OutputTarget, TransformFields,
class_for_io_error, classified_error, convert_error, convert_usage, is_dash,
map_transform_error, optimize_error, optimize_usage, read_input_bytes, read_url_bytes,
runtime_error, validate_url,
};
fn watermark_is_a_url(watermark: &Path) -> bool {
let Some(value) = watermark.to_str() else {
return false;
};
let Some((scheme, _)) = value.split_once("://") else {
return false;
};
!scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn read_watermark_bytes(watermark: &Path) -> Result<Vec<u8>, CliError> {
if watermark_is_a_url(watermark) {
let value = watermark.to_str().expect("a URL is valid UTF-8");
validate_url(value, "--watermark")?;
return read_url_bytes(value, MAX_REMOTE_WATERMARK_BYTES);
}
fs::read(watermark).map_err(|error| {
classified_error(
class_for_io_error(&error),
EXIT_IO,
&format!("failed to read watermark {}: {error}", watermark.display()),
)
})
}
#[cfg(test)]
mod watermark_tests {
use super::watermark_is_a_url;
use std::path::Path;
#[test]
fn a_value_naming_a_scheme_is_a_url_and_everything_else_is_a_path() {
let urls = [
"http://example.com/logo.png",
"https://example.com/logo.png",
"HTTP://example.com/logo.png",
"ftp://example.com/logo.png",
"file:///etc/hosts",
"gopher://example.com/logo.png",
];
for value in urls {
assert!(
watermark_is_a_url(Path::new(value)),
"{value} names a scheme, so it is a URL"
);
}
let paths = [
"logo.png",
"./logo.png",
"/var/lib/logo.png",
"../logo.png",
"logo:1.png",
"mailto:someone@example.com",
"C:\\images\\logo.png",
"c:/images/logo.png",
];
for value in paths {
assert!(
!watermark_is_a_url(Path::new(value)),
"{value} is a path, not a URL"
);
}
}
}
pub(super) fn convert_from_clap(args: ClapConvertArgs) -> Result<Command, CliError> {
if args.help {
return Ok(Command::Help(HelpTopic::Convert));
}
let input = match (&args.url, &args.input) {
(Some(url), None) => {
validate_url(url, "--url")?;
InputSource::Url(url.clone())
}
(None, Some(value)) if is_dash(value) => InputSource::Stdin,
(None, Some(value)) => InputSource::Path(value.clone()),
(None, None) => {
return Err(CliError {
exit_code: EXIT_USAGE,
class: ErrorClass::InvalidRequest,
message: "'convert' requires an input file, URL, or -".to_string(),
usage: Some(convert_usage().to_string()),
hint: Some("try 'truss convert input.png -o output.jpg'".to_string()),
});
}
(Some(_), Some(_)) => {
return Err(convert_error("'convert' accepts exactly one input"));
}
};
let output = match args.output {
Some(ref value) if is_dash(value) => OutputTarget::Stdout,
Some(ref value) => OutputTarget::Path(value.clone()),
None => {
return Err(CliError {
exit_code: EXIT_USAGE,
class: ErrorClass::InvalidRequest,
message: "'convert' requires -o <output>".to_string(),
usage: Some(convert_usage().to_string()),
hint: Some("try 'truss convert input.png -o output.jpg'".to_string()),
});
}
};
if args.format.is_none() {
reject_unencodable_output_extension(&output, convert_error)?;
}
let watermark_path = args.watermark.clone();
if watermark_path.is_none()
&& (args.watermark_position.is_some()
|| args.watermark_opacity.is_some()
|| args.watermark_margin.is_some())
{
return Err(CliError {
exit_code: EXIT_USAGE,
class: ErrorClass::InvalidRequest,
message: "--watermark-position, --watermark-opacity, and --watermark-margin require --watermark".to_string(),
usage: Some(convert_usage().to_string()),
hint: Some("provide --watermark <file or URL> when using watermark options".to_string()),
});
}
let watermark_position = args.watermark_position;
let watermark_opacity = args.watermark_opacity;
let watermark_margin = args.watermark_margin;
let options = TransformFields {
width: args.width,
height: args.height,
fit: args.fit,
position: args.position,
format: args.format,
quality: args.quality,
optimize: args.optimize,
target_quality: args.target_quality,
background: args.background,
rotate: args.rotate,
auto_orient: args.auto_orient,
no_auto_orient: args.no_auto_orient,
strip_metadata: args.strip_metadata,
keep_metadata: args.keep_metadata,
preserve_exif: args.preserve_exif,
crop: args.crop,
blur: args.blur,
sharpen: args.sharpen,
grayscale: args.grayscale,
without_enlargement: args.without_enlargement,
}
.into_options()
.map_err(map_transform_error)?;
Ok(Command::Convert(ConvertCommand {
input,
output,
options,
watermark_path,
watermark_position,
watermark_opacity,
watermark_margin,
}))
}
pub(super) fn optimize_from_clap(args: ClapOptimizeArgs) -> Result<Command, CliError> {
if args.help {
return Ok(Command::Help(HelpTopic::Optimize));
}
let input = match (&args.url, &args.input) {
(Some(url), None) => {
validate_url(url, "--url")?;
InputSource::Url(url.clone())
}
(None, Some(value)) if is_dash(value) => InputSource::Stdin,
(None, Some(value)) => InputSource::Path(value.clone()),
(None, None) => {
return Err(CliError {
exit_code: EXIT_USAGE,
class: ErrorClass::InvalidRequest,
message: "'optimize' requires an input file, URL, or -".to_string(),
usage: Some(optimize_usage().to_string()),
hint: Some("try 'truss optimize input.jpg -o output.jpg'".to_string()),
});
}
(Some(_), Some(_)) => {
return Err(optimize_error("'optimize' accepts exactly one input"));
}
};
let output = match args.output {
Some(ref value) if is_dash(value) => OutputTarget::Stdout,
Some(ref value) => OutputTarget::Path(value.clone()),
None => {
return Err(CliError {
exit_code: EXIT_USAGE,
class: ErrorClass::InvalidRequest,
message: "'optimize' requires -o <output>".to_string(),
usage: Some(optimize_usage().to_string()),
hint: Some("try 'truss optimize input.jpg -o output.jpg'".to_string()),
});
}
};
if args.format.is_none() {
reject_unencodable_output_extension(&output, optimize_error)?;
}
let options = TransformFields {
width: None,
height: None,
fit: None,
position: None,
format: args.format,
quality: args.quality,
optimize: Some(args.mode.unwrap_or(crate::OptimizeMode::Auto)),
target_quality: args.target_quality,
background: None,
rotate: None,
auto_orient: args.auto_orient,
no_auto_orient: args.no_auto_orient,
strip_metadata: args.strip_metadata,
keep_metadata: args.keep_metadata,
preserve_exif: args.preserve_exif,
crop: None,
blur: None,
sharpen: None,
grayscale: false,
without_enlargement: false,
}
.into_options()
.map_err(map_transform_error)?;
Ok(Command::Optimize(ConvertCommand {
input,
output,
options,
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
}))
}
pub(super) fn execute_convert<R, W>(
command: ConvertCommand,
stdin: &mut R,
stdout: &mut W,
) -> Result<(), CliError>
where
R: Read,
W: Write,
{
let bytes = read_input_bytes(command.input, stdin)?;
let input = sniff_artifact(RawArtifact::new(bytes, None)).map_err(map_transform_error)?;
let mut options = command.options;
if options.format.is_none() {
options.format = infer_output_format(&command.output);
}
let watermark = if let Some(ref wm_path) = command.watermark_path {
let wm_bytes = read_watermark_bytes(wm_path)?;
let wm_artifact = sniff_artifact(RawArtifact::new(wm_bytes, None)).map_err(|error| {
let mut failure = map_transform_error(error);
failure.message = format!(
"failed to decode watermark '{}': {}",
wm_path.display(),
failure.message
);
failure
})?;
let mut watermark = WatermarkInput::new(wm_artifact);
watermark.position = command.watermark_position.unwrap_or(watermark.position);
watermark.opacity = command.watermark_opacity.unwrap_or(watermark.opacity);
watermark.margin = command.watermark_margin.unwrap_or(watermark.margin);
Some(watermark)
} else {
None
};
let mut request = TransformRequest::new(input, options);
request.watermark = watermark;
let result = transform(request).map_err(map_transform_error)?;
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
write_output_bytes(command.output, &result.artifact.bytes, stdout)
}
fn reject_unencodable_output_extension<F>(output: &OutputTarget, error: F) -> Result<(), CliError>
where
F: Fn(&str) -> CliError,
{
let Some(media_type) = infer_output_format(output) else {
return Ok(());
};
match media_type.unencodable_reason() {
Some(reason) => Err(error(&reason)),
None => Ok(()),
}
}
fn infer_output_format(output: &OutputTarget) -> Option<MediaType> {
match output {
OutputTarget::Stdout => None,
OutputTarget::Path(path) => infer_output_format_from_path(path),
}
}
fn infer_output_format_from_path(path: &Path) -> Option<MediaType> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
std::str::FromStr::from_str(&extension).ok()
}
fn write_output_bytes<W>(output: OutputTarget, bytes: &[u8], stdout: &mut W) -> Result<(), CliError>
where
W: Write,
{
match output {
OutputTarget::Stdout => stdout.write_all(bytes).map_err(|error| {
runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
}),
OutputTarget::Path(path) => replace_file(&path, bytes).map_err(|error| {
classified_error(
class_for_io_error(&error),
EXIT_IO,
&format!("failed to write {}: {error}", path.display()),
)
}),
}
}
fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if !is_replaceable_destination(path) {
return fs::write(path, bytes);
}
refuse_a_destination_the_caller_may_not_write(path)?;
let Some(temporary) = temporary_sibling(path) else {
return fs::write(path, bytes);
};
let Ok(mut file) = fs::File::create(&temporary) else {
return fs::write(path, bytes);
};
let outcome = (|| -> std::io::Result<()> {
file.write_all(bytes)?;
drop(file);
copy_permissions(path, &temporary);
fs::rename(&temporary, path)
})();
if outcome.is_err() {
let _ = fs::remove_file(&temporary);
}
outcome
}
fn refuse_a_destination_the_caller_may_not_write(path: &Path) -> std::io::Result<()> {
match fs::OpenOptions::new().write(true).open(path) {
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn is_replaceable_destination(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(metadata) => metadata.file_type().is_file(),
Err(error) => error.kind() == std::io::ErrorKind::NotFound,
}
}
static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temporary_sibling(path: &Path) -> Option<PathBuf> {
let sequence = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
let name = format!(".truss.{}.{sequence}.tmp", std::process::id());
Some(path.with_file_name(name))
}
fn copy_permissions(path: &Path, temporary: &Path) {
if let Ok(metadata) = fs::metadata(path) {
let _ = fs::set_permissions(temporary, metadata.permissions());
}
}
#[cfg(test)]
mod tests {
use super::{OutputTarget, temporary_sibling, write_output_bytes};
use std::fs;
use std::path::PathBuf;
fn temp_dir(name: &str) -> PathBuf {
let path = crate::test_support::unique_temp_path(&format!("truss-{name}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
#[cfg(unix)]
#[test]
fn replacing_a_file_swaps_it_in_rather_than_truncating_it() {
use std::os::unix::fs::MetadataExt;
let dir = temp_dir("write-swap");
let destination = dir.join("out.png");
fs::write(&destination, b"the bytes that were already there").expect("write it");
let before = fs::metadata(&destination).expect("stat it").ino();
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let after = fs::metadata(&destination).expect("stat it again").ino();
let content = fs::read(&destination).expect("read it back");
let _ = fs::remove_dir_all(&dir);
assert_eq!(content, b"new");
assert_ne!(
before, after,
"the destination was written in place, so a failure partway would truncate it"
);
}
#[cfg(unix)]
#[test]
fn a_long_destination_name_is_still_replaced_rather_than_truncated() {
use std::os::unix::fs::MetadataExt;
let dir = temp_dir("write-swap-long-name");
let destination = dir.join(format!("{}.png", "a".repeat(246)));
fs::write(&destination, b"the bytes that were already there").expect("write it");
let before = fs::metadata(&destination).expect("stat it").ino();
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let after = fs::metadata(&destination).expect("stat it again").ino();
let content = fs::read(&destination).expect("read it back");
let _ = fs::remove_dir_all(&dir);
assert_eq!(content, b"new");
assert_ne!(
before, after,
"a 250-byte destination name lost the atomic replace, so a failure partway would truncate it"
);
}
#[test]
fn two_replacements_do_not_share_a_temporary() {
let dir = temp_dir("write-temp-uniqueness");
let first = dir.join("first.png");
let second = dir.join("second.png");
let names: Vec<_> = [&first, &second, &first, &second]
.iter()
.map(|path| temporary_sibling(path).expect("a destination has a file name"))
.collect();
let mut unique: Vec<_> = names.iter().collect();
unique.sort();
unique.dedup();
let _ = fs::remove_dir_all(&dir);
assert_eq!(
unique.len(),
names.len(),
"two writes must not race for one temporary: {names:?}"
);
for name in &names {
assert_eq!(
name.parent(),
Some(dir.as_path()),
"the temporary sits beside its destination so the rename cannot cross a file system"
);
}
}
#[test]
fn a_replacement_that_fails_leaves_nothing_behind() {
let dir = temp_dir("write-failure");
let destination = dir.join("occupied");
fs::create_dir(&destination).expect("create the destination directory");
fs::write(destination.join("inside"), b"still here").expect("fill it");
let mut stdout = Vec::new();
let result =
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout);
let inside = fs::read(destination.join("inside")).expect("read what was inside");
let leftovers: Vec<PathBuf> = fs::read_dir(&dir)
.expect("list the directory")
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path != &destination)
.collect();
let _ = fs::remove_dir_all(&dir);
assert!(result.is_err(), "the write failed and has to say so");
assert_eq!(inside, b"still here");
assert!(
leftovers.is_empty(),
"a failed write left files behind: {leftovers:?}"
);
}
#[test]
fn a_successful_write_leaves_no_other_file_behind() {
let dir = temp_dir("write-success");
let destination = dir.join("out.png");
fs::write(&destination, b"old").expect("write the destination");
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let entries: Vec<PathBuf> = fs::read_dir(&dir)
.expect("list the directory")
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect();
let content = fs::read(&destination).expect("read the destination");
let _ = fs::remove_dir_all(&dir);
assert_eq!(content, b"new");
assert_eq!(entries.len(), 1, "the directory holds only the output");
}
#[cfg(unix)]
#[test]
fn replacing_a_file_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("write-permissions");
let destination = dir.join("out.png");
fs::write(&destination, b"old").expect("write the destination");
fs::set_permissions(&destination, fs::Permissions::from_mode(0o640))
.expect("set the destination mode");
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let mode = fs::metadata(&destination)
.expect("read the destination metadata")
.permissions()
.mode()
& 0o777;
let _ = fs::remove_dir_all(&dir);
assert_eq!(mode, 0o640, "the mode of the replaced file is kept");
}
#[cfg(unix)]
#[test]
fn a_named_pipe_destination_is_written_through() {
use std::io::Read;
use std::os::unix::fs::FileTypeExt;
let dir = temp_dir("write-fifo");
let destination = dir.join("pipe.png");
let path = std::ffi::CString::new(destination.as_os_str().as_encoded_bytes())
.expect("a path with no interior nul");
let made = unsafe { libc::mkfifo(path.as_ptr(), 0o600) };
assert_eq!(made, 0, "create the fifo");
let (sender, receiver) = std::sync::mpsc::channel();
let reader_path = destination.clone();
std::thread::spawn(move || {
let seen = fs::File::open(&reader_path).and_then(|mut file| {
let mut seen = Vec::new();
file.read_to_end(&mut seen).map(|_| seen)
});
let _ = sender.send(seen);
});
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let seen = receiver
.recv_timeout(std::time::Duration::from_secs(10))
.expect("the reader has to see the write rather than wait on a pipe nobody holds")
.expect("read the fifo");
let still_a_fifo = fs::metadata(&destination)
.expect("stat the destination")
.file_type()
.is_fifo();
let _ = fs::remove_dir_all(&dir);
assert_eq!(
seen, b"new",
"the bytes have to reach whoever is reading the pipe"
);
assert!(
still_a_fifo,
"a pipe is a destination to write through, not to replace"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_destination_is_followed() {
let dir = temp_dir("write-symlink");
let target = dir.join("dated.png");
let link = dir.join("latest.png");
fs::write(&target, b"old").expect("write the target");
std::os::unix::fs::symlink(&target, &link).expect("make the link");
let mut stdout = Vec::new();
write_output_bytes(OutputTarget::Path(link.clone()), b"new", &mut stdout)
.expect("the write succeeds");
let still_a_link = fs::symlink_metadata(&link)
.expect("stat the link")
.file_type()
.is_symlink();
let target_content = fs::read(&target).expect("read the target");
let _ = fs::remove_dir_all(&dir);
assert!(
still_a_link,
"the link is what the caller named, and it stays a link"
);
assert_eq!(
target_content, b"new",
"what the link points at is what was written"
);
}
#[cfg(unix)]
#[test]
fn a_read_only_destination_is_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("write-read-only-file");
let destination = dir.join("out.png");
fs::write(&destination, b"old").expect("write the destination");
fs::set_permissions(&destination, fs::Permissions::from_mode(0o400))
.expect("seal the file");
let mut stdout = Vec::new();
let result =
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout);
let content = fs::read(&destination).expect("read the destination");
let _ = fs::set_permissions(&destination, fs::Permissions::from_mode(0o600));
let _ = fs::remove_dir_all(&dir);
if unsafe { libc::geteuid() } == 0 {
return;
}
assert!(
result.is_err(),
"a file the caller may not write is not written"
);
assert_eq!(content, b"old", "and its contents are left alone");
}
#[cfg(unix)]
#[test]
fn a_file_in_a_read_only_directory_is_still_replaced() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("write-read-only-dir");
let destination = dir.join("out.png");
fs::write(&destination, b"old").expect("write the destination");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).expect("seal the directory");
let mut stdout = Vec::new();
let result =
write_output_bytes(OutputTarget::Path(destination.clone()), b"new", &mut stdout);
let content = fs::read(&destination).expect("read the destination");
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o755));
let _ = fs::remove_dir_all(&dir);
if result.is_err() {
return;
}
assert_eq!(content, b"new");
}
}