use crate::adapters::server::{self, SignedUrlSource, sign_public_url_with_method};
use std::io::Write;
use super::{
ClapSignArgs, CliError, Command, EXIT_RUNTIME, HelpTopic, SignCommand, TransformFields,
map_transform_error, runtime_error, sign_error, usage_error,
};
pub(super) fn sign_from_clap(args: ClapSignArgs) -> Result<Command, CliError> {
if args.help {
return Ok(Command::Help(HelpTopic::Sign));
}
let base_url = args
.base_url
.ok_or_else(|| sign_error("'sign' requires --base-url"))?;
let source = match (args.path, args.url) {
(Some(path), None) => SignedUrlSource::Path {
path,
version: args.version,
},
(None, Some(url)) => SignedUrlSource::Url {
url,
version: args.version,
},
(None, None) => {
return Err(sign_error("'sign' requires exactly one of --path or --url"));
}
(Some(_), Some(_)) => {
return Err(sign_error("'sign' accepts exactly one of --path or --url"));
}
};
let key_id = args
.key_id
.ok_or_else(|| sign_error("'sign' requires --key-id"))?;
let secret = args
.secret
.ok_or_else(|| sign_error("'sign' requires --secret"))?;
let expires = args
.expires
.ok_or_else(|| sign_error("'sign' requires --expires"))?;
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)?;
options
.validate_without_input()
.map_err(map_transform_error)?;
Ok(Command::Sign(SignCommand {
base_url,
method: args.method.unwrap_or_default(),
source,
key_id,
secret,
expires,
options,
watermark_url: args.watermark_url,
watermark_position: args.watermark_position,
watermark_opacity: args.watermark_opacity,
watermark_margin: args.watermark_margin,
preset: args.preset,
}))
}
pub(super) fn execute_sign<W>(command: SignCommand, stdout: &mut W) -> Result<(), CliError>
where
W: Write,
{
if command.watermark_url.is_none()
&& (command.watermark_position.is_some()
|| command.watermark_opacity.is_some()
|| command.watermark_margin.is_some())
{
return Err(usage_error(
"--watermark-position, --watermark-opacity, and --watermark-margin require --watermark-url",
));
}
if let Some(reason) =
server::signing_input_error(&command.key_id, &command.secret, &command.source)
{
return Err(usage_error(reason));
}
let watermark_params =
command
.watermark_url
.as_ref()
.map(|url| server::SignedWatermarkParams {
url: url.clone(),
position: command.watermark_position.map(|p| p.as_name().to_string()),
opacity: command.watermark_opacity,
margin: command.watermark_margin,
});
let url = sign_public_url_with_method(
command.method.as_str(),
&command.base_url,
command.source,
&command.options,
&command.key_id,
&command.secret,
command.expires,
watermark_params.as_ref(),
command.preset.as_deref(),
)
.map_err(|reason| runtime_error(EXIT_RUNTIME, &reason))?;
writeln!(stdout, "{url}").map_err(|error| {
runtime_error(EXIT_RUNTIME, &format!("failed to write output: {error}"))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapters::cli::SignedMethod;
use crate::{Position, TransformOptions};
use std::io;
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::other("writer failure"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn base_command() -> SignCommand {
SignCommand {
base_url: "https://cdn.example.com".to_string(),
method: SignedMethod::Get,
source: SignedUrlSource::Path {
path: "/image.png".to_string(),
version: None,
},
key_id: "public-dev".to_string(),
secret: "secret-value".to_string(),
expires: 4_102_444_800,
options: TransformOptions::default(),
watermark_url: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
preset: None,
}
}
#[test]
fn execute_sign_refuses_credentials_and_a_source_the_server_cannot_accept() {
let cases: [(SignCommand, &str); 3] = [
(
SignCommand {
key_id: String::new(),
..base_command()
},
"key id must not be empty",
),
(
SignCommand {
secret: String::new(),
..base_command()
},
"secret must not be empty",
),
(
SignCommand {
source: SignedUrlSource::Path {
path: String::new(),
version: None,
},
..base_command()
},
"path must not be empty",
),
];
for (command, message) in cases {
let mut stdout = Vec::new();
let error = execute_sign(command, &mut stdout).expect_err("the signer must refuse it");
assert_eq!(error.exit_code, crate::adapters::cli::EXIT_USAGE);
assert!(
error.message.contains(message),
"expected {message:?}, got: {}",
error.message
);
assert!(stdout.is_empty(), "nothing is written for a refusal");
}
}
#[test]
fn execute_sign_signs_the_method_it_was_given() {
let mut get_out = Vec::new();
execute_sign(base_command(), &mut get_out).expect("GET signs");
let get_url = String::from_utf8(get_out).expect("utf-8");
let mut head_out = Vec::new();
execute_sign(
SignCommand {
method: SignedMethod::Head,
..base_command()
},
&mut head_out,
)
.expect("HEAD signs");
let head_url = String::from_utf8(head_out).expect("utf-8");
let strip = |url: &str| {
url.split('&')
.filter(|part| !part.starts_with("signature="))
.collect::<Vec<_>>()
.join("&")
};
assert_eq!(strip(&get_url), strip(&head_url));
assert_ne!(
get_url, head_url,
"a HEAD URL must not carry the signature of its GET"
);
}
#[test]
fn a_method_the_signed_routes_do_not_serve_is_refused() {
assert_eq!("GET".parse::<SignedMethod>(), Ok(SignedMethod::Get));
assert_eq!("HEAD".parse::<SignedMethod>(), Ok(SignedMethod::Head));
assert_eq!("head".parse::<SignedMethod>(), Ok(SignedMethod::Head));
assert_eq!(
"POST".parse::<SignedMethod>(),
Err("unsupported signed URL method `POST`".to_string())
);
}
#[test]
fn execute_sign_rejects_orphaned_watermark_options() {
let mut stdout = Vec::new();
let error = execute_sign(
SignCommand {
watermark_position: Some(Position::Center),
..base_command()
},
&mut stdout,
)
.expect_err("watermark position without URL should fail");
assert_eq!(error.exit_code, crate::adapters::cli::EXIT_USAGE);
assert!(error.message.contains("require --watermark-url"));
}
#[test]
fn execute_sign_surfaces_writer_failures() {
let error = execute_sign(base_command(), &mut FailingWriter)
.expect_err("writer failure should be reported");
assert_eq!(error.exit_code, super::EXIT_RUNTIME);
assert!(error.message.contains("failed to write output"));
}
}