1use crate::adapters::server::SignedUrlSource;
2use crate::{
3 CropRegion, Fit, MediaType, OptimizeMode, Position, Rgba8, Rotation, TargetQuality,
4 TransformOptions,
5};
6use clap::{CommandFactory, Parser, Subcommand};
7use std::ffi::{OsStr, OsString};
8use std::fs;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::ExitCode;
12
13use crate::core::error_class::ErrorClass;
14use std::str::FromStr;
15
16mod convert;
17mod inspect;
18mod serve;
19mod sign;
20
21const MAX_REMOTE_BYTES: u64 = crate::adapters::server::remote::MAX_SOURCE_BYTES;
28
29pub(super) const MAX_REMOTE_WATERMARK_BYTES: u64 =
35 crate::adapters::server::remote::MAX_WATERMARK_BYTES;
36
37const EXIT_SUCCESS: u8 = 0;
43const EXIT_USAGE: u8 = 1;
45const EXIT_IO: u8 = 2;
47const EXIT_INPUT: u8 = 3;
49const EXIT_TRANSFORM: u8 = 4;
51const EXIT_RUNTIME: u8 = 5;
53
54fn help_top_level() -> String {
59 format!(
60 "\
61truss {version} - an image transformation tool and server
62
63Converts, resizes, and re-encodes images (JPEG, PNG, WebP, AVIF, BMP, TIFF, SVG).
64Can also run as an HTTP image-transform server.
65
66USAGE:
67 truss <COMMAND> [OPTIONS]
68 truss <INPUT> -o <OUTPUT> [OPTIONS] (implicit convert)
69 truss --bind <ADDR> [OPTIONS] (implicit serve)
70
71COMMANDS:
72 convert Convert and transform an image file
73 optimize Optimize an image for smaller output size
74 inspect Show metadata (format, dimensions, alpha) of an image
75 serve Start the HTTP image-transform server
76 validate Check server configuration without starting the server
77 sign Generate a signed public URL for the server
78 completions Generate shell completion scripts
79 help Show help for a command (e.g. truss help convert)
80
81OPTIONS:
82 -V, --version Print version information
83
84EXAMPLES:
85 truss photo.png -o photo.jpg --width 800
86 truss inspect photo.jpg
87 truss serve --bind 0.0.0.0:8080 --storage-root /var/images
88 truss sign --base-url https://cdn.example.com --path /hero.jpg \\
89 --key-id mykey --secret s3cret --expires 1700000000
90 truss completions bash > ~/.local/share/bash-completion/completions/truss
91
92Run 'truss help <command>' for more information on a specific command.
93
94EXIT CODES:
95 0 Success
96 1 Usage error (bad arguments)
97 2 I/O error (file not found, permission denied, network failure)
98 3 Input error (unsupported format, corrupt file)
99 4 Transform error (encode failure, size limit exceeded, deadline)
100 5 Runtime error (bind failure, stdout write failure)
101
102Sponsor: https://github.com/sponsors/nao1215
103",
104 version = env!("CARGO_PKG_VERSION"),
105 )
106}
107
108const HELP_CONVERT: &str = "\
109truss convert - convert and transform an image file
110
111USAGE:
112 truss convert <INPUT> -o <OUTPUT> [OPTIONS]
113 truss convert --url <URL> -o <OUTPUT> [OPTIONS]
114 truss convert - -o - --format jpeg (stdin to stdout)
115
116 The 'convert' subcommand can be omitted:
117 truss <INPUT> -o <OUTPUT> [OPTIONS]
118 truss --url <URL> -o <OUTPUT> [OPTIONS]
119
120 For a file whose name starts with -, put the options first and end them with --,
121 or write the path with a ./ prefix. An output cannot be escaped with --, because
122 -o takes the next argument whatever it looks like; assign it with = instead:
123 truss convert -o out.jpg -- -input.png
124 truss convert input.png --output=-output.jpg
125
126OPTIONS:
127 -o, --output <OUTPUT> Output file path, or - for stdout (required)
128 --url <URL> Fetch input from an HTTP(S) URL
129 --width <PX> Target width in pixels
130 --height <PX> Target height in pixels
131 --fit <MODE> How to fit into target dimensions (requires --width and --height)
132 contain: scale to fit, then pad to the exact box (default)
133 cover: scale to fill the box, cropping the excess
134 fill: stretch each axis to the box, ignoring aspect ratio
135 inside: scale to fit, no padding; the output is at most the
136 box and usually smaller on one axis
137 --position <POS> Crop anchor for cover mode (default: center)
138 center, top, right, bottom, left,
139 top-left, top-right, bottom-left, bottom-right
140 --format <FMT> Output format: jpeg, png, webp, avif, bmp, tiff, svg
141 (default: inferred from output extension)
142 --quality <1-100> Encoding quality for lossy formats
143 --optimize <MODE> Optimization mode: none, auto, lossless, lossy
144 --target-quality <TARGET>
145 Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
146 --background <COLOR> Background color as RRGGBB or RRGGBBAA hex
147 --rotate <DEG> Rotate clockwise by whole degrees. Negative turns counter-clockwise,
148 and angles past a full turn wrap, so -90 and 270 are the same.
149 A multiple of 90 is exact; any other angle resamples and grows
150 the canvas to the rotated bounding box, filling the exposed
151 corners with --background (transparent, or white for formats
152 without alpha)
153 --auto-orient Apply EXIF orientation and reset tag (default)
154 --no-auto-orient Skip EXIF orientation correction (add --keep-metadata to keep
155 the tag; stripping it as well leaves the image rotated)
156 --strip-metadata Remove all metadata (default; lossy optimization keeps the
157 ICC profile so colors are not shifted by the re-encode)
158 --keep-metadata Preserve EXIF, ICC, and other supported metadata
159 --preserve-exif Preserve EXIF only (strip ICC and others)
160 --crop <x,y,w,h> Explicit crop region as x,y,width,height (applied before resize)
161 --blur <SIGMA> Gaussian blur sigma (0.1-100.0)
162 --sharpen <SIGMA> Sharpen sigma (0.1-100.0)
163 --grayscale Desaturate the image to grayscale (applied after resize, blur,
164 and sharpen, and before the watermark)
165 --without-enlargement
166 Never scale an image up. A source already within the requested
167 size keeps that size. Combines with any --fit; contain still
168 pads out to the full box, and cover returns the box intersected
169 with the source rather than the whole box
170 --watermark <FILE|URL>
171 Watermark image to composite onto the output, from a file or an
172 HTTP(S) URL of at most 10 MB. The overlay itself must be a raster
173 image; the picture it goes onto may be an SVG
174 --watermark-position <POS> Watermark placement (default: bottom-right)
175 center, top, right, bottom, left,
176 top-left, top-right, bottom-left, bottom-right
177 --watermark-opacity <1-100> Watermark opacity percentage (default: 50)
178 --watermark-margin <PX> Margin from edge in pixels (default: 10)
179
180EXAMPLES:
181 truss photo.png -o photo.jpg --width 800
182 truss --url https://example.com/img.png -o out.webp --format webp --quality 75
183 cat photo.png | truss convert - -o - --format jpeg > photo.jpg
184 truss photo.png -o thumb.png --width 200 --height 200 --fit cover
185 truss diagram.svg -o safe.svg
186 truss diagram.svg -o diagram.png --width 1024
187
188SVG:
189 An SVG output sanitizes the document and returns it as written, so an option that asks
190 for a different picture is refused rather than ignored. Convert to a raster format to
191 resize, rotate, or recolour a drawing; --fit, --position, and --without-enlargement then
192 mean what they mean for any other input.
193
194ORDER:
195 Options are applied in a fixed order, whatever order they are written in:
196 auto-orient, rotate, crop, resize, blur, sharpen, grayscale, watermark, encode.
197 https://github.com/nao1215/truss/blob/main/docs/pipeline.md
198";
199
200const HELP_OPTIMIZE: &str = "\
201truss optimize - reduce image file size with format-aware optimization
202
203USAGE:
204 truss optimize <INPUT> -o <OUTPUT> [OPTIONS]
205 truss optimize --url <URL> -o <OUTPUT> [OPTIONS]
206 truss optimize - -o - --format webp
207
208OPTIONS:
209 -o, --output <OUTPUT> Output file path, or - for stdout (required)
210 --url <URL> Fetch input from an HTTP(S) URL
211 --format <FMT> Output format: jpeg, png, webp, avif
212 (default: inferred from output extension or input format)
213 --mode <MODE> Optimization mode: auto (default), lossless, lossy
214 For a plain re-encode with no optimization, use truss convert
215 lossless cannot rotate pixels, so a JPEG carrying an EXIF
216 orientation needs --keep-metadata or --preserve-exif
217 --quality <1-100> Optional quality cap for lossy optimization
218 --target-quality <TARGET>
219 Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
220 --auto-orient Apply EXIF orientation and reset tag (default)
221 --no-auto-orient Skip EXIF orientation correction (add --keep-metadata to keep
222 the tag; stripping it as well leaves the image rotated)
223 --strip-metadata Remove all metadata (default; lossy optimization keeps the
224 ICC profile so colors are not shifted by the re-encode)
225 --keep-metadata Preserve EXIF, ICC, and other supported metadata
226 --preserve-exif Preserve EXIF only (strip ICC and others)
227
228EXAMPLES:
229 truss optimize photo.jpg -o out.jpg
230 truss optimize photo.jpg -o out.jpg --mode lossy --target-quality ssim:0.98
231 truss optimize graphic.png -o out.png --mode lossless
232";
233
234const HELP_INSPECT: &str = "\
235truss inspect - show metadata of an image
236
237USAGE:
238 truss inspect <FILE>
239 truss inspect --url <URL>
240 truss inspect - (read from stdin)
241
242 Use -- to separate options from file paths starting with -:
243 truss inspect -- -weird-name.png
244
245OUTPUT:
246 Prints JSON with format, MIME type, dimensions, alpha, and animation info.
247
248 width/height are the dimensions as stored in the file. orientedWidth/orientedHeight
249 are what 'truss convert' produces: an EXIF orientation of 5 to 8 transposes them, and
250 orientation reports the tag when the file carries one.
251
252EXAMPLES:
253 truss inspect photo.jpg
254 truss inspect --url https://example.com/photo.jpg
255 cat photo.png | truss inspect -
256";
257
258fn help_serve() -> String {
259 let mut s = String::from(
260 "\
261truss serve - start the HTTP image-transform server
262
263USAGE:
264 truss serve [OPTIONS]
265
266 Server flags can also be used at the top level:
267 truss --bind 0.0.0.0:8080 --storage-root /var/images
268
269OPTIONS:
270 --bind <ADDR> Listen address (default: 127.0.0.1:8080)
271 --storage-root <PATH> Root directory for path-based sources
272 --public-base-url <URL> External base URL for signed URLs
273 --signed-url-key-id <KEY_ID> Key identifier for signed public URLs
274 --signed-url-secret <SECRET> Shared secret for HMAC verification
275 --allow-insecure-url-sources Allow private-network URLs (dev/test only)
276
277ENVIRONMENT VARIABLES:
278 Defaults, ranges and the full description of each setting are in docs/configuration.md.
279
280 Core:
281 TRUSS_BIND_ADDR Listen address override
282 TRUSS_STORAGE_ROOT Storage root override (default: working directory)
283 TRUSS_BEARER_TOKEN Private API authentication token
284",
285 );
286
287 {
290 use std::fmt::Write as FmtWrite;
291
292 #[allow(unused_mut, clippy::useless_vec)]
293 let mut backends = vec!["filesystem (default)"];
294 #[cfg(feature = "s3")]
295 backends.push("s3");
296 #[cfg(feature = "gcs")]
297 backends.push("gcs");
298 #[cfg(feature = "azure")]
299 backends.push("azure");
300 let _ = writeln!(
301 s,
302 " TRUSS_STORAGE_BACKEND Source for public by-path resolution: {}",
303 backends.join(", ")
304 );
305 }
306
307 s.push_str(
308 " TRUSS_MAX_CONCURRENT_TRANSFORMS Concurrent transforms before a request gets 503
309 TRUSS_TRANSFORM_DEADLINE_SECS Per-transform deadline in seconds
310 TRUSS_MAX_INPUT_PIXELS Input pixels accepted before decode
311 TRUSS_MAX_UPLOAD_BYTES Upload body size accepted
312 TRUSS_MAX_SOURCE_BYTES Source image size accepted from disk or a URL
313 TRUSS_MAX_WATERMARK_BYTES Watermark image size accepted from a URL
314 TRUSS_MAX_REMOTE_REDIRECTS Redirects followed when fetching a remote URL
315 TRUSS_STORAGE_TIMEOUT_SECS Download timeout for object storage backends
316 TRUSS_KEEP_ALIVE_MAX_REQUESTS Requests per keep-alive connection
317 TRUSS_SHUTDOWN_DRAIN_SECS Drain period during graceful shutdown
318 TRUSS_RESPONSE_HEADERS Custom image-response headers as JSON
319 TRUSS_DISABLE_COMPRESSION Turn off gzip for non-image responses
320 TRUSS_COMPRESSION_LEVEL Gzip level for non-image responses
321 TRUSS_LOG_LEVEL Log verbosity: error, warn, info, debug
322
323 Health and load:
324 TRUSS_HEALTH_TOKEN Bearer token for GET /health
325 TRUSS_HEALTH_CACHE_MIN_FREE_BYTES Free bytes on the cache disk /health/ready needs
326 TRUSS_HEALTH_MAX_MEMORY_BYTES Process RSS /health/ready allows (Linux)
327 TRUSS_HEALTH_HYSTERESIS_MARGIN Recovery margin for the readiness thresholds
328 TRUSS_HEALTH_CACHE_TTL_SECS How long /health/ready reuses a measurement
329 TRUSS_RATE_LIMIT_RPS Sustained per-client requests per second
330 TRUSS_RATE_LIMIT_BURST Requests a client may send back to back
331 TRUSS_TRUSTED_PROXIES IPs or CIDRs whose forwarded-for headers are trusted
332
333 Metrics:
334 TRUSS_METRICS_TOKEN Bearer token for GET /metrics
335 TRUSS_DISABLE_METRICS Turn the /metrics endpoint off entirely
336
337 Signed URLs, caching and presets:
338 TRUSS_PUBLIC_BASE_URL Public base URL override
339 TRUSS_SIGNING_KEYS Multiple signing keys as JSON {\"keyId\":\"secret\",...}
340 TRUSS_SIGNED_URL_KEY_ID Signing key identifier (single-key shorthand)
341 TRUSS_SIGNED_URL_SECRET Signing shared secret (single-key shorthand)
342 TRUSS_CACHE_ROOT On-disk transform cache directory
343 TRUSS_CACHE_MAX_BYTES Size budget for the cache directory
344 TRUSS_PUBLIC_MAX_AGE Cache-Control max-age for public GET responses
345 TRUSS_PUBLIC_STALE_WHILE_REVALIDATE Cache-Control stale-while-revalidate for the same
346 TRUSS_FORMAT_PREFERENCE Output formats ordered by server preference
347 TRUSS_DISABLE_ACCEPT_NEGOTIATION Turn off Accept-based content negotiation
348 TRUSS_ALLOW_INSECURE_URL_SOURCES Enable insecure URL sources
349 TRUSS_PRESETS Named transform presets as inline JSON
350 TRUSS_PRESETS_FILE Path to a JSON file containing named transform presets
351",
352 );
353
354 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
355 s.push_str("\n Storage backend:\n");
356
357 #[cfg(feature = "s3")]
358 s.push_str(
359 " TRUSS_S3_BUCKET Default S3 bucket name (required when backend=s3)
360 TRUSS_S3_FORCE_PATH_STYLE Use path-style S3 addressing (set to 1/true/yes/on for MinIO, etc.)
361 AWS_ACCESS_KEY_ID AWS access key for S3 authentication
362 AWS_SECRET_ACCESS_KEY AWS secret key for S3 authentication
363 AWS_REGION AWS region for the S3 client (e.g. us-east-1)
364 AWS_ENDPOINT_URL Custom S3-compatible endpoint URL (e.g. http://minio:9000)
365",
366 );
367
368 #[cfg(feature = "gcs")]
369 s.push_str(
370 " TRUSS_GCS_BUCKET Default GCS bucket name (required when backend=gcs)
371 TRUSS_GCS_ENDPOINT Custom GCS endpoint URL (for testing with fake-gcs-server, etc.)
372 GOOGLE_APPLICATION_CREDENTIALS Path to GCS service account JSON key file
373 GOOGLE_APPLICATION_CREDENTIALS_JSON Service account JSON key given inline
374",
375 );
376
377 #[cfg(feature = "azure")]
378 s.push_str(
379 " TRUSS_AZURE_CONTAINER Default Azure container name (required when backend=azure)
380 TRUSS_AZURE_ENDPOINT Custom Azure Blob endpoint URL (for Azurite, etc.)
381 AZURE_STORAGE_ACCOUNT_NAME Storage account name (derives endpoint when TRUSS_AZURE_ENDPOINT is unset)
382",
383 );
384
385 #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
386 s.push_str(
387 "\
388\nNOTE: When using local emulators (MinIO, fake-gcs-server, Azurite), set
389 TRUSS_ALLOW_INSECURE_URL_SOURCES=true to allow plain-HTTP endpoints.
390",
391 );
392
393 s.push_str(
394 "\
395\nEXAMPLES:
396 truss serve --bind 0.0.0.0:8080 --storage-root /var/images
397 truss serve --bind 127.0.0.1:3000 --signed-url-key-id mykey --signed-url-secret s3cret
398",
399 );
400 s
401}
402
403const HELP_SIGN: &str = "\
404truss sign - generate a signed public URL
405
406USAGE:
407 truss sign --base-url <URL> --path <PATH> \\
408 --key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
409 truss sign --base-url <URL> --url <URL> \\
410 --key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
411
412REQUIRED:
413 --base-url <URL> CDN base URL for the signed request
414 --path <PATH> Image path on the server (mutually exclusive with --url)
415 --url <URL> Remote image URL to transform (mutually exclusive with --path)
416 --key-id <KEY_ID> Signing key identifier
417 --secret <SECRET> HMAC shared secret
418 --expires <UNIX_SECS> Expiration as Unix timestamp
419
420OPTIONAL:
421 --version <VALUE> Cache-busting version tag
422 --method <METHOD> HTTP method the URL is signed for: get or head (default: get).
423 The signature covers the method, so a URL signed for one is
424 refused for the other
425 --width, --height, --fit, --position, --format, --quality,
426 --optimize, --target-quality, --background, --rotate, --auto-orient, --no-auto-orient,
427 --strip-metadata, --keep-metadata, --preserve-exif, --crop, --blur, --sharpen,
428 --grayscale, --without-enlargement
429 --watermark-url <URL> Watermark image URL to embed in the signed URL
430 --watermark-position <POS> Watermark placement (default: bottom-right)
431 --watermark-opacity <1-100> Watermark opacity (default: 50)
432 --watermark-margin <PX> Watermark margin from edge in pixels (default: 10)
433 --preset <NAME> Named transform preset (server-side)
434
435EXAMPLES:
436 truss sign --base-url https://cdn.example.com \\
437 --path /photos/hero.jpg --key-id mykey --secret s3cret \\
438 --expires 1700000000 --width 640 --format webp
439";
440
441const HELP_VALIDATE: &str = "\
442truss validate - check server configuration without starting the server
443
444USAGE:
445 truss validate
446
447Parses and validates all environment variables used by `truss serve`.
448Exits 0 when the configuration is valid, or exits 1 with a description
449of each error found.
450
451Useful in CI/CD pipelines to catch configuration mistakes early.
452";
453
454const HELP_COMPLETIONS: &str = "\
455truss completions - generate shell completion scripts
456
457USAGE:
458 truss completions <SHELL>
459
460SHELLS:
461 bash, zsh, fish, elvish, powershell
462
463EXAMPLES:
464 truss completions bash > ~/.local/share/bash-completion/completions/truss
465 truss completions zsh > ~/.zfunc/_truss
466 truss completions fish > ~/.config/fish/completions/truss.fish
467";
468
469const HELP_VERSION: &str = "\
470truss version - print version information
471
472USAGE:
473 truss version
474 truss -V
475 truss --version
476";
477
478#[derive(Parser)]
483#[command(
484 name = "truss",
485 about = "an image transformation tool and server",
486 disable_help_subcommand = true,
487 disable_help_flag = true,
488 disable_version_flag = true
489)]
490struct Cli {
491 #[command(subcommand)]
492 command: Option<CliSubcommand>,
493}
494
495#[derive(Subcommand)]
496enum CliSubcommand {
497 #[command(disable_help_flag = true)]
499 Convert(ClapConvertArgs),
500 #[command(disable_help_flag = true)]
502 Optimize(ClapOptimizeArgs),
503 #[command(disable_help_flag = true)]
505 Inspect(ClapInspectArgs),
506 #[command(disable_help_flag = true)]
508 Serve(ClapServeArgs),
509 #[command(disable_help_flag = true)]
511 Sign(ClapSignArgs),
512 Help { topic: Option<String> },
514 Version,
516 #[command(disable_help_flag = true)]
518 Validate(ClapValidateArgs),
519 #[command(disable_help_flag = true)]
521 Completions {
522 #[arg(value_enum)]
523 shell: Option<clap_complete::Shell>,
524 #[arg(long)]
526 help: bool,
527 },
528}
529
530#[derive(clap::Args)]
531struct ClapConvertArgs {
532 #[arg(allow_hyphen_values = true)]
534 input: Option<PathBuf>,
535 #[arg(short = 'o', long = "output", allow_hyphen_values = true)]
537 output: Option<PathBuf>,
538 #[arg(long)]
540 url: Option<String>,
541 #[arg(long, value_parser = parse_width)]
543 width: Option<u32>,
544 #[arg(long, value_parser = parse_height)]
546 height: Option<u32>,
547 #[arg(long, value_parser = parse_fit)]
549 fit: Option<Fit>,
550 #[arg(long, value_parser = parse_position)]
552 position: Option<Position>,
553 #[arg(long, value_parser = parse_media_type)]
555 format: Option<MediaType>,
556 #[arg(long, value_parser = parse_quality)]
558 quality: Option<u8>,
559 #[arg(long, value_parser = parse_optimize_mode)]
561 optimize: Option<OptimizeMode>,
562 #[arg(long = "target-quality", value_parser = parse_target_quality)]
564 target_quality: Option<TargetQuality>,
565 #[arg(long, value_parser = parse_background)]
567 background: Option<Rgba8>,
568 #[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
573 rotate: Option<Rotation>,
574 #[arg(long)]
576 auto_orient: bool,
577 #[arg(long)]
579 no_auto_orient: bool,
580 #[arg(long)]
582 strip_metadata: bool,
583 #[arg(long)]
585 keep_metadata: bool,
586 #[arg(long)]
588 preserve_exif: bool,
589 #[arg(long, value_parser = parse_crop, allow_hyphen_values = true)]
596 crop: Option<CropRegion>,
597 #[arg(long, value_parser = parse_blur)]
599 blur: Option<f32>,
600 #[arg(long, value_parser = parse_sharpen)]
602 sharpen: Option<f32>,
603 #[arg(long)]
605 grayscale: bool,
606 #[arg(long)]
608 without_enlargement: bool,
609 #[arg(long)]
611 watermark: Option<PathBuf>,
612 #[arg(long, value_parser = parse_position)]
614 watermark_position: Option<Position>,
615 #[arg(long, value_parser = parse_watermark_opacity)]
617 watermark_opacity: Option<u8>,
618 #[arg(long, value_parser = parse_watermark_margin)]
620 watermark_margin: Option<u32>,
621 #[arg(short = 'h', long = "help")]
623 help: bool,
624}
625
626#[derive(clap::Args)]
627struct ClapOptimizeArgs {
628 #[arg(allow_hyphen_values = true)]
630 input: Option<PathBuf>,
631 #[arg(short = 'o', long = "output", allow_hyphen_values = true)]
633 output: Option<PathBuf>,
634 #[arg(long)]
636 url: Option<String>,
637 #[arg(long, value_parser = parse_optimizable_media_type)]
639 format: Option<MediaType>,
640 #[arg(long, value_parser = parse_quality)]
642 quality: Option<u8>,
643 #[arg(long = "mode", value_parser = parse_optimizing_mode)]
645 mode: Option<OptimizeMode>,
646 #[arg(long = "target-quality", value_parser = parse_target_quality)]
648 target_quality: Option<TargetQuality>,
649 #[arg(long)]
651 auto_orient: bool,
652 #[arg(long)]
654 no_auto_orient: bool,
655 #[arg(long)]
657 strip_metadata: bool,
658 #[arg(long)]
660 keep_metadata: bool,
661 #[arg(long)]
663 preserve_exif: bool,
664 #[arg(short = 'h', long = "help")]
666 help: bool,
667}
668
669#[derive(clap::Args)]
670struct ClapInspectArgs {
671 #[arg(allow_hyphen_values = true)]
673 input: Option<PathBuf>,
674 #[arg(long)]
676 url: Option<String>,
677 #[arg(short = 'h', long = "help")]
679 help: bool,
680}
681
682#[derive(clap::Args)]
683struct ClapServeArgs {
684 #[arg(long)]
686 bind: Option<String>,
687 #[arg(long)]
689 storage_root: Option<PathBuf>,
690 #[arg(long, value_parser = parse_url_value)]
692 public_base_url: Option<String>,
693 #[arg(long)]
695 signed_url_key_id: Option<String>,
696 #[arg(long)]
698 signed_url_secret: Option<String>,
699 #[arg(long)]
701 allow_insecure_url_sources: bool,
702 #[arg(short = 'h', long = "help")]
704 help: bool,
705}
706
707#[derive(clap::Args)]
708struct ClapValidateArgs {
709 #[arg(short = 'h', long = "help")]
711 help: bool,
712}
713
714#[derive(clap::Args)]
715struct ClapSignArgs {
716 #[arg(long, value_parser = parse_url_value)]
718 base_url: Option<String>,
719 #[arg(long)]
721 path: Option<String>,
722 #[arg(long, value_parser = parse_url_value)]
724 url: Option<String>,
725 #[arg(long)]
727 version: Option<String>,
728 #[arg(long, value_parser = parse_signed_method)]
730 method: Option<SignedMethod>,
731 #[arg(long)]
733 key_id: Option<String>,
734 #[arg(long)]
736 secret: Option<String>,
737 #[arg(long)]
739 expires: Option<u64>,
740 #[arg(long, value_parser = parse_width)]
742 width: Option<u32>,
743 #[arg(long, value_parser = parse_height)]
745 height: Option<u32>,
746 #[arg(long, value_parser = parse_fit)]
748 fit: Option<Fit>,
749 #[arg(long, value_parser = parse_position)]
751 position: Option<Position>,
752 #[arg(long, value_parser = parse_media_type)]
754 format: Option<MediaType>,
755 #[arg(long, value_parser = parse_quality)]
757 quality: Option<u8>,
758 #[arg(long, value_parser = parse_optimize_mode)]
760 optimize: Option<OptimizeMode>,
761 #[arg(long = "target-quality", value_parser = parse_target_quality)]
763 target_quality: Option<TargetQuality>,
764 #[arg(long, value_parser = parse_background)]
766 background: Option<Rgba8>,
767 #[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
772 rotate: Option<Rotation>,
773 #[arg(long)]
775 auto_orient: bool,
776 #[arg(long)]
778 no_auto_orient: bool,
779 #[arg(long)]
781 strip_metadata: bool,
782 #[arg(long)]
784 keep_metadata: bool,
785 #[arg(long)]
787 preserve_exif: bool,
788 #[arg(long, value_parser = parse_crop, allow_hyphen_values = true)]
795 crop: Option<CropRegion>,
796 #[arg(long, value_parser = parse_blur)]
798 blur: Option<f32>,
799 #[arg(long, value_parser = parse_sharpen)]
801 sharpen: Option<f32>,
802 #[arg(long)]
804 grayscale: bool,
805 #[arg(long)]
807 without_enlargement: bool,
808 #[arg(long, value_parser = parse_url_value)]
810 watermark_url: Option<String>,
811 #[arg(long, value_parser = parse_position)]
813 watermark_position: Option<Position>,
814 #[arg(long, value_parser = parse_watermark_opacity)]
816 watermark_opacity: Option<u8>,
817 #[arg(long, value_parser = parse_watermark_margin)]
819 watermark_margin: Option<u32>,
820 #[arg(long)]
822 preset: Option<String>,
823 #[arg(short = 'h', long = "help")]
825 help: bool,
826}
827
828fn parse_fit(s: &str) -> Result<Fit, String> {
833 Fit::from_str(s)
834}
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
843enum SignedMethod {
844 #[default]
845 Get,
846 Head,
847}
848
849impl SignedMethod {
850 fn as_str(self) -> &'static str {
851 match self {
852 Self::Get => "GET",
853 Self::Head => "HEAD",
854 }
855 }
856}
857
858impl FromStr for SignedMethod {
859 type Err = String;
860
861 fn from_str(s: &str) -> Result<Self, Self::Err> {
862 match s.to_ascii_uppercase().as_str() {
866 "GET" => Ok(Self::Get),
867 "HEAD" => Ok(Self::Head),
868 _ => Err(format!("unsupported signed URL method `{s}`")),
869 }
870 }
871}
872
873fn parse_signed_method(s: &str) -> Result<SignedMethod, String> {
874 SignedMethod::from_str(s)
875}
876
877fn parse_position(s: &str) -> Result<Position, String> {
878 Position::from_str(s)
879}
880
881fn parse_media_type(s: &str) -> Result<MediaType, String> {
886 let media_type = MediaType::from_str(s)?;
887 match media_type.unencodable_reason() {
888 Some(reason) => Err(reason),
889 None => Ok(media_type),
890 }
891}
892
893fn parse_optimizable_media_type(s: &str) -> Result<MediaType, String> {
894 let media_type = parse_media_type(s)?;
895 if media_type.supports_optimization() {
896 Ok(media_type)
897 } else {
898 Err(format!(
899 "optimization is not supported for {} output",
900 media_type.as_name()
901 ))
902 }
903}
904
905fn parse_optimize_mode(s: &str) -> Result<OptimizeMode, String> {
906 OptimizeMode::from_str(s)
907}
908
909fn parse_optimizing_mode(s: &str) -> Result<OptimizeMode, String> {
917 match OptimizeMode::from_str(s)? {
918 OptimizeMode::None => Err(
919 "`none` does not optimize; use `truss convert` for a plain re-encode, or one of auto, lossless, lossy"
920 .to_string(),
921 ),
922 mode => Ok(mode),
923 }
924}
925
926fn parse_target_quality(s: &str) -> Result<TargetQuality, String> {
927 TargetQuality::from_str(s)
928}
929
930fn parse_rotation(s: &str) -> Result<Rotation, String> {
931 Rotation::from_str(s)
932}
933
934fn parse_background(s: &str) -> Result<Rgba8, String> {
935 Rgba8::from_hex(s)
936}
937
938fn parse_crop(s: &str) -> Result<CropRegion, String> {
939 CropRegion::from_str(s)
940}
941
942fn parse_blur(s: &str) -> Result<f32, String> {
951 parse_sigma(s, "blur")
952}
953
954fn parse_sharpen(s: &str) -> Result<f32, String> {
956 parse_sigma(s, "sharpen")
957}
958
959fn parse_sigma(s: &str, name: &str) -> Result<f32, String> {
960 s.parse::<f32>()
961 .map_err(|_| format!("{name} sigma must be a number, got '{s}'"))
962}
963
964fn parse_watermark_margin(s: &str) -> Result<u32, String> {
972 parse_dimension(
973 s,
974 "watermark margin",
975 crate::core::validate_watermark_margin_value,
976 )
977}
978
979fn parse_width(s: &str) -> Result<u32, String> {
985 parse_dimension(s, "width", crate::core::validate_width_value)
986}
987
988fn parse_height(s: &str) -> Result<u32, String> {
990 parse_dimension(s, "height", crate::core::validate_height_value)
991}
992
993fn parse_dimension(
994 s: &str,
995 axis: &str,
996 validate: fn(i64) -> Result<u32, &'static str>,
997) -> Result<u32, String> {
998 let value: i64 = s
999 .parse()
1000 .map_err(|_| format!("{axis} must be a whole number of pixels, got '{s}'"))?;
1001 validate(value).map_err(str::to_string)
1004}
1005
1006fn parse_quality(s: &str) -> Result<u8, String> {
1007 let value: i64 = s
1008 .parse()
1009 .map_err(|_| format!("quality must be a whole number, got '{s}'"))?;
1010 u8::try_from(value).map_err(|_| {
1015 crate::core::validate_quality_value(value)
1016 .expect_err("a value outside u8 is outside 1..=100")
1017 .to_string()
1018 })
1019}
1020
1021fn parse_watermark_opacity(s: &str) -> Result<u8, String> {
1022 let value: i64 = s
1023 .parse()
1024 .map_err(|_| format!("watermark opacity must be a whole number, got '{s}'"))?;
1025 crate::core::validate_watermark_opacity_value(value).map_err(str::to_string)
1026}
1027
1028fn parse_url_value(s: &str) -> Result<String, String> {
1029 let parsed = url::Url::parse(s).map_err(|e| format!("invalid URL: {e}"))?;
1030 match parsed.scheme() {
1031 "http" | "https" => Ok(s.to_string()),
1032 _ => Err(format!("requires an http:// or https:// URL, got '{s}'")),
1033 }
1034}
1035
1036fn convert_usage() -> &'static str {
1041 "usage: truss convert <INPUT> -o <OUTPUT> [OPTIONS]"
1042}
1043
1044fn optimize_usage() -> &'static str {
1045 "usage: truss optimize <INPUT> -o <OUTPUT> [OPTIONS]"
1046}
1047
1048fn inspect_usage() -> &'static str {
1049 "usage: truss inspect <FILE|--url URL|->"
1050}
1051
1052fn serve_usage() -> &'static str {
1053 "usage: truss serve [--bind ADDR] [--storage-root PATH] [OPTIONS]"
1054}
1055
1056fn sign_usage() -> &'static str {
1057 "usage: truss sign --base-url <URL> (--path <PATH>|--url <URL>) --key-id <ID> --secret <SECRET> --expires <UNIX_SECS>"
1058}
1059
1060pub fn run<I>(args: I) -> ExitCode
1097where
1098 I: IntoIterator<Item: Into<OsString>>,
1099{
1100 let stdin = io::stdin();
1101 let stdout = io::stdout();
1102 let stderr = io::stderr();
1103 let mut stdin = stdin.lock();
1104 let mut stdout = stdout.lock();
1105 let mut stderr = stderr.lock();
1106
1107 let code = run_with_io(args, &mut stdin, &mut stdout, &mut stderr);
1108
1109 ExitCode::from(flush_stdout(code, &mut stdout, &mut stderr))
1110}
1111
1112fn flush_stdout<W, E>(code: u8, stdout: &mut W, stderr: &mut E) -> u8
1122where
1123 W: Write,
1124 E: Write,
1125{
1126 match stdout.flush() {
1127 Ok(()) => code,
1128 Err(error) if code == EXIT_SUCCESS => write_error(stderr, stdout_write_error(&error)),
1129 Err(_) => code,
1130 }
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Eq)]
1138enum HelpTopic {
1139 TopLevel,
1140 Convert,
1141 Optimize,
1142 Inspect,
1143 Serve,
1144 Validate,
1145 Sign,
1146 Completions,
1147 Version,
1148}
1149
1150#[derive(Debug, Clone, PartialEq)]
1151enum Command {
1152 Help(HelpTopic),
1153 Version,
1154 Serve(ServeCommand),
1155 Validate,
1156 Inspect(InspectCommand),
1157 Convert(ConvertCommand),
1158 Optimize(ConvertCommand),
1159 Sign(SignCommand),
1160 Completions(clap_complete::Shell),
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Eq)]
1164struct ServeCommand {
1165 bind_addr: Option<String>,
1166 storage_root: Option<PathBuf>,
1167 public_base_url: Option<String>,
1168 signed_url_key_id: Option<String>,
1169 signed_url_secret: Option<String>,
1170 allow_insecure_url_sources: bool,
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1174struct InspectCommand {
1175 input: InputSource,
1176}
1177
1178#[derive(Debug, Clone, PartialEq)]
1179struct ConvertCommand {
1180 input: InputSource,
1181 output: OutputTarget,
1182 options: TransformOptions,
1183 watermark_path: Option<PathBuf>,
1184 watermark_position: Option<Position>,
1185 watermark_opacity: Option<u8>,
1186 watermark_margin: Option<u32>,
1187}
1188
1189#[derive(Debug, Clone, PartialEq)]
1190struct SignCommand {
1191 base_url: String,
1192 method: SignedMethod,
1193 source: SignedUrlSource,
1194 key_id: String,
1195 secret: String,
1196 expires: u64,
1197 options: TransformOptions,
1198 watermark_url: Option<String>,
1199 watermark_position: Option<Position>,
1200 watermark_opacity: Option<u8>,
1201 watermark_margin: Option<u32>,
1202 preset: Option<String>,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq)]
1206enum InputSource {
1207 Stdin,
1208 Path(PathBuf),
1209 Url(String),
1210}
1211
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213enum OutputTarget {
1214 Stdout,
1215 Path(PathBuf),
1216}
1217
1218#[derive(Debug, Clone, PartialEq, Eq)]
1229struct CliError {
1230 exit_code: u8,
1231 class: ErrorClass,
1232 message: String,
1233 usage: Option<String>,
1234 hint: Option<String>,
1235}
1236
1237fn run_with_io<I, R, W, E>(args: I, stdin: &mut R, stdout: &mut W, stderr: &mut E) -> u8
1242where
1243 I: IntoIterator<Item: Into<OsString>>,
1244 R: Read,
1245 W: Write,
1246 E: Write,
1247{
1248 match parse_args(args) {
1249 Ok(Command::Help(topic)) => {
1250 let text = match topic {
1251 HelpTopic::TopLevel => help_top_level(),
1252 HelpTopic::Convert => HELP_CONVERT.to_string(),
1253 HelpTopic::Optimize => HELP_OPTIMIZE.to_string(),
1254 HelpTopic::Inspect => HELP_INSPECT.to_string(),
1255 HelpTopic::Serve => help_serve(),
1256 HelpTopic::Validate => HELP_VALIDATE.to_string(),
1257 HelpTopic::Sign => HELP_SIGN.to_string(),
1258 HelpTopic::Completions => HELP_COMPLETIONS.to_string(),
1259 HelpTopic::Version => HELP_VERSION.to_string(),
1260 };
1261 match stdout.write_all(text.as_bytes()) {
1262 Ok(()) => EXIT_SUCCESS,
1263 Err(error) => write_error(stderr, stdout_write_error(&error)),
1264 }
1265 }
1266 Ok(Command::Version) => match writeln!(stdout, "truss {}", env!("CARGO_PKG_VERSION")) {
1267 Ok(()) => EXIT_SUCCESS,
1268 Err(error) => write_error(stderr, stdout_write_error(&error)),
1269 },
1270 Ok(Command::Serve(command)) => match serve::execute_serve(command) {
1271 Ok(()) => EXIT_SUCCESS,
1272 Err(error) => write_error(stderr, error),
1273 },
1274 Ok(Command::Validate) => match serve::execute_validate(stdout) {
1275 Ok(()) => EXIT_SUCCESS,
1276 Err(error) => write_error(stderr, error),
1277 },
1278 Ok(Command::Inspect(command)) => match inspect::execute_inspect(command, stdin, stdout) {
1279 Ok(()) => EXIT_SUCCESS,
1280 Err(error) => write_error(stderr, error),
1281 },
1282 Ok(Command::Convert(command) | Command::Optimize(command)) => {
1283 match convert::execute_convert(command, stdin, stdout) {
1284 Ok(()) => EXIT_SUCCESS,
1285 Err(error) => write_error(stderr, error),
1286 }
1287 }
1288 Ok(Command::Sign(command)) => match sign::execute_sign(command, stdout) {
1289 Ok(()) => EXIT_SUCCESS,
1290 Err(error) => write_error(stderr, error),
1291 },
1292 Ok(Command::Completions(shell)) => match generate_completions(shell, stdout) {
1293 Ok(()) => EXIT_SUCCESS,
1294 Err(error) => write_error(stderr, error),
1295 },
1296 Err(error) => write_error(stderr, error),
1297 }
1298}
1299
1300const KNOWN_SUBCOMMANDS: &[&str] = &[
1305 "convert",
1306 "optimize",
1307 "inspect",
1308 "serve",
1309 "validate",
1310 "sign",
1311 "help",
1312 "completions",
1313 "version",
1314];
1315
1316fn is_serve_flag(value: &str) -> bool {
1317 matches!(
1318 value,
1319 "--bind"
1320 | "--storage-root"
1321 | "--public-base-url"
1322 | "--signed-url-key-id"
1323 | "--signed-url-secret"
1324 | "--allow-insecure-url-sources"
1325 )
1326}
1327
1328fn looks_like_unknown_subcommand(value: &str) -> bool {
1331 if value.starts_with('-') || value.starts_with('/') || value.starts_with('.') {
1332 return false;
1333 }
1334 if value.contains('.') || value.contains('/') || value.contains('\\') {
1335 return false;
1336 }
1337 value
1338 .chars()
1339 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1340}
1341
1342fn is_dash(path: &Path) -> bool {
1349 path.as_os_str() == OsStr::new("-")
1350}
1351
1352fn preprocess_args(args: Vec<OsString>) -> Vec<OsString> {
1353 if args.len() <= 1 {
1354 return args;
1355 }
1356 let first = &args[1];
1357 let first_text = first.to_str();
1361
1362 if first == "-h" || first == "--help" {
1364 let mut new = vec![args[0].clone(), OsString::from("help")];
1365 if args.len() > 2 {
1366 new.extend_from_slice(&args[2..]);
1367 }
1368 return new;
1369 }
1370
1371 if first == "-V" || first == "--version" {
1373 return vec![args[0].clone(), OsString::from("version")];
1374 }
1375
1376 if first_text.is_some_and(is_serve_flag) {
1378 let mut new = vec![args[0].clone(), OsString::from("serve")];
1379 new.extend_from_slice(&args[1..]);
1380 return new;
1381 }
1382
1383 if first_text.is_some_and(|first| KNOWN_SUBCOMMANDS.contains(&first)) {
1385 return args;
1386 }
1387
1388 if std::path::Path::new(first).is_file() {
1393 let mut new = vec![args[0].clone(), OsString::from("convert")];
1394 new.extend_from_slice(&args[1..]);
1395 return new;
1396 }
1397
1398 if first_text.is_some_and(looks_like_unknown_subcommand) {
1401 return args;
1402 }
1403
1404 let mut new = vec![args[0].clone(), OsString::from("convert")];
1406 new.extend_from_slice(&args[1..]);
1407 new
1408}
1409
1410fn parse_args<I>(args: I) -> Result<Command, CliError>
1415where
1416 I: IntoIterator<Item: Into<OsString>>,
1417{
1418 let raw: Vec<OsString> = args.into_iter().map(Into::into).collect();
1419
1420 if raw.len() <= 1 {
1422 return Ok(Command::Help(HelpTopic::TopLevel));
1423 }
1424
1425 let preprocessed = preprocess_args(raw);
1426 let cli = Cli::try_parse_from(&preprocessed).map_err(map_clap_error)?;
1427
1428 match cli.command {
1429 None => Ok(Command::Help(HelpTopic::TopLevel)),
1430 Some(CliSubcommand::Help { topic }) => parse_help_topic(topic),
1431 Some(CliSubcommand::Version) => Ok(Command::Version),
1432 Some(CliSubcommand::Completions { help: true, .. }) => {
1433 Ok(Command::Help(HelpTopic::Completions))
1434 }
1435 Some(CliSubcommand::Completions {
1436 shell: Some(shell), ..
1437 }) => Ok(Command::Completions(shell)),
1438 Some(CliSubcommand::Completions {
1439 shell: None,
1440 help: false,
1441 }) => Err(CliError {
1442 exit_code: EXIT_USAGE,
1443 class: ErrorClass::InvalidRequest,
1444 message: "'completions' requires a shell argument".to_string(),
1445 usage: None,
1446 hint: Some("try 'truss completions bash'".to_string()),
1447 }),
1448 Some(CliSubcommand::Convert(args)) => convert::convert_from_clap(args),
1449 Some(CliSubcommand::Optimize(args)) => convert::optimize_from_clap(args),
1450 Some(CliSubcommand::Inspect(args)) => inspect::inspect_from_clap(args),
1451 Some(CliSubcommand::Serve(args)) => serve::serve_from_clap(args),
1452 Some(CliSubcommand::Validate(args)) => serve::validate_from_clap(args),
1453 Some(CliSubcommand::Sign(args)) => sign::sign_from_clap(args),
1454 }
1455}
1456
1457fn map_clap_error(err: clap::Error) -> CliError {
1459 let raw = err.to_string();
1460 let message = raw
1462 .strip_prefix("error: ")
1463 .unwrap_or(&raw)
1464 .trim()
1465 .to_string();
1466
1467 CliError {
1468 exit_code: EXIT_USAGE,
1469 class: ErrorClass::InvalidRequest,
1470 message,
1471 usage: None,
1472 hint: Some("run 'truss --help' for available commands".to_string()),
1473 }
1474}
1475
1476fn parse_help_topic(topic: Option<String>) -> Result<Command, CliError> {
1477 match topic.as_deref() {
1478 None => Ok(Command::Help(HelpTopic::TopLevel)),
1479 Some("convert") => Ok(Command::Help(HelpTopic::Convert)),
1480 Some("optimize") => Ok(Command::Help(HelpTopic::Optimize)),
1481 Some("inspect") => Ok(Command::Help(HelpTopic::Inspect)),
1482 Some("serve") => Ok(Command::Help(HelpTopic::Serve)),
1483 Some("validate") => Ok(Command::Help(HelpTopic::Validate)),
1484 Some("sign") => Ok(Command::Help(HelpTopic::Sign)),
1485 Some("completions") => Ok(Command::Help(HelpTopic::Completions)),
1486 Some("version") => Ok(Command::Help(HelpTopic::Version)),
1487 Some(other) => Err(CliError {
1488 exit_code: EXIT_USAGE,
1489 class: ErrorClass::InvalidRequest,
1490 message: format!("unknown help topic '{other}'"),
1491 usage: None,
1492 hint: Some(
1493 "available topics: convert, optimize, inspect, serve, validate, sign, completions, version"
1494 .to_string(),
1495 ),
1496 }),
1497 }
1498}
1499
1500struct TransformFields {
1506 width: Option<u32>,
1507 height: Option<u32>,
1508 fit: Option<Fit>,
1509 position: Option<Position>,
1510 format: Option<MediaType>,
1511 quality: Option<u8>,
1512 optimize: Option<OptimizeMode>,
1513 target_quality: Option<TargetQuality>,
1514 background: Option<Rgba8>,
1515 rotate: Option<Rotation>,
1516 auto_orient: bool,
1517 no_auto_orient: bool,
1518 strip_metadata: bool,
1519 keep_metadata: bool,
1520 preserve_exif: bool,
1521 crop: Option<CropRegion>,
1522 blur: Option<f32>,
1523 sharpen: Option<f32>,
1524 grayscale: bool,
1525 without_enlargement: bool,
1526}
1527
1528impl TransformFields {
1529 fn into_options(self) -> Result<TransformOptions, crate::TransformError> {
1530 let defaults = TransformOptions::default();
1531 let auto_orient = if self.no_auto_orient {
1532 false
1533 } else if self.auto_orient {
1534 true
1535 } else {
1536 defaults.auto_orient
1537 };
1538 let (strip_metadata, preserve_exif) = crate::core::resolve_metadata_flags(
1539 if self.strip_metadata {
1540 Some(true)
1541 } else {
1542 None
1543 },
1544 if self.keep_metadata { Some(true) } else { None },
1545 if self.preserve_exif { Some(true) } else { None },
1546 )?;
1547 Ok(TransformOptions {
1548 width: self.width,
1549 height: self.height,
1550 fit: self.fit,
1551 position: self.position,
1552 format: self.format,
1553 quality: self.quality,
1554 optimize: self.optimize.unwrap_or(defaults.optimize),
1555 target_quality: self.target_quality,
1556 background: self.background,
1557 rotate: self.rotate.unwrap_or(defaults.rotate),
1558 auto_orient,
1559 strip_metadata,
1560 preserve_exif,
1561 crop: self.crop,
1562 blur: self.blur,
1563 sharpen: self.sharpen,
1564 grayscale: self.grayscale,
1565 without_enlargement: self.without_enlargement,
1566 deadline: None,
1567 })
1568 }
1569}
1570
1571fn validate_url(url: &str, flag: &str) -> Result<(), CliError> {
1572 let parsed = url::Url::parse(url).map_err(|e| CliError {
1573 exit_code: EXIT_USAGE,
1574 class: ErrorClass::InvalidRequest,
1575 message: format!("'{flag}' is not a valid URL: {e}"),
1576 usage: None,
1577 hint: Some(format!("got '{url}'")),
1578 })?;
1579 match parsed.scheme() {
1580 "http" | "https" => Ok(()),
1581 _ => Err(CliError {
1582 exit_code: EXIT_USAGE,
1583 class: ErrorClass::InvalidRequest,
1584 message: format!("'{flag}' requires an http:// or https:// URL"),
1585 usage: None,
1586 hint: Some(format!("got '{url}'")),
1587 }),
1588 }
1589}
1590
1591fn generate_completions<W: Write>(
1593 shell: clap_complete::Shell,
1594 stdout: &mut W,
1595) -> Result<(), CliError> {
1596 let mut cmd = Cli::command();
1597
1598 cmd = cmd
1602 .arg(
1603 clap::Arg::new("INPUT")
1604 .help("Input image file (implicit convert)")
1605 .value_hint(clap::ValueHint::FilePath),
1606 )
1607 .arg(
1608 clap::Arg::new("output")
1609 .short('o')
1610 .long("output")
1611 .help("Output file path (implicit convert)")
1612 .value_hint(clap::ValueHint::FilePath),
1613 )
1614 .arg(
1615 clap::Arg::new("bind")
1616 .long("bind")
1617 .help("Listen address (implicit serve)"),
1618 )
1619 .arg(
1620 clap::Arg::new("storage-root")
1621 .long("storage-root")
1622 .help("Root directory for path-based sources (implicit serve)")
1623 .value_hint(clap::ValueHint::DirPath),
1624 );
1625
1626 clap_complete::generate(shell, &mut cmd, "truss", stdout);
1627 Ok(())
1628}
1629
1630fn read_input_bytes<R>(input: InputSource, stdin: &mut R) -> Result<Vec<u8>, CliError>
1635where
1636 R: Read,
1637{
1638 match input {
1639 InputSource::Stdin => {
1640 let mut bytes = Vec::new();
1641 stdin.read_to_end(&mut bytes).map_err(|error| {
1642 runtime_error(EXIT_IO, &format!("failed to read stdin: {error}"))
1643 })?;
1644 Ok(bytes)
1645 }
1646 InputSource::Path(path) => fs::read(&path).map_err(|error| {
1647 classified_error(
1648 class_for_io_error(&error),
1649 EXIT_IO,
1650 &format!("failed to read {}: {error}", path.display()),
1651 )
1652 }),
1653 InputSource::Url(url) => read_url_bytes(&url, MAX_REMOTE_BYTES),
1654 }
1655}
1656
1657fn class_for_io_error(error: &io::Error) -> ErrorClass {
1664 if error.kind() == io::ErrorKind::NotFound {
1665 ErrorClass::NotFound
1666 } else {
1667 ErrorClass::InternalError
1668 }
1669}
1670
1671const CLI_FETCH_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1673const CLI_FETCH_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
1675const CLI_FETCH_MAX_REDIRECTS: u32 = 5;
1680
1681fn refuse_disallowed_fetch_target(url: &str) -> Result<(), CliError> {
1694 let parsed = url::Url::parse(url).map_err(|error| {
1695 classified_error(
1696 ErrorClass::BadGateway,
1697 EXIT_IO,
1698 &format!("failed to fetch {url}: not a valid URL: {error}"),
1699 )
1700 })?;
1701 if !matches!(parsed.scheme(), "http" | "https") {
1702 return Err(classified_error(
1703 ErrorClass::BadGateway,
1704 EXIT_IO,
1705 &format!(
1706 "failed to fetch {url}: a redirect to a `{}` URL is not followed",
1707 parsed.scheme()
1708 ),
1709 ));
1710 }
1711 let named_metadata = crate::core::remote_policy::is_cloud_metadata_host(&parsed);
1712 let resolves_to_metadata = !named_metadata
1715 && parsed
1716 .socket_addrs(|| Some(if parsed.scheme() == "https" { 443 } else { 80 }))
1717 .map(|addrs| {
1718 addrs
1719 .iter()
1720 .any(|addr| crate::core::remote_policy::is_cloud_metadata_ip(addr.ip()))
1721 })
1722 .unwrap_or(false);
1723 if named_metadata || resolves_to_metadata {
1724 return Err(classified_error(
1725 ErrorClass::BadGateway,
1726 EXIT_IO,
1727 &format!("failed to fetch {url}: the URL points to a cloud metadata service"),
1728 ));
1729 }
1730 Ok(())
1731}
1732
1733fn read_url_bytes(url: &str, max_bytes: u64) -> Result<Vec<u8>, CliError> {
1741 let fetch_failed =
1742 |message: String| classified_error(ErrorClass::BadGateway, EXIT_IO, &message);
1743 let config = ureq::config::Config::builder()
1744 .timeout_connect(Some(CLI_FETCH_CONNECT_TIMEOUT))
1745 .timeout_recv_body(Some(CLI_FETCH_BODY_TIMEOUT))
1746 .http_status_as_error(false)
1747 .max_redirects(0)
1748 .build();
1749 let agent = ureq::Agent::new_with_config(config);
1750
1751 let mut current = url.to_string();
1752 let mut response = None;
1753 for _ in 0..=CLI_FETCH_MAX_REDIRECTS {
1754 refuse_disallowed_fetch_target(¤t)?;
1755 let hop = agent
1756 .get(¤t)
1757 .call()
1758 .map_err(|error| fetch_failed(format!("failed to fetch {current}: {error}")))?;
1759 let status = hop.status().as_u16();
1760 if crate::core::remote_policy::is_redirect_status(status) {
1761 let location = hop
1762 .headers()
1763 .get("Location")
1764 .and_then(|value: &ureq::http::HeaderValue| value.to_str().ok())
1765 .ok_or_else(|| {
1766 fetch_failed(format!(
1767 "failed to fetch {current}: HTTP {status} without a Location header"
1768 ))
1769 })?;
1770 let base = url::Url::parse(¤t).map_err(|error| {
1771 fetch_failed(format!(
1772 "failed to fetch {current}: not a valid URL: {error}"
1773 ))
1774 })?;
1775 let next = base.join(location).map_err(|error| {
1776 fetch_failed(format!(
1777 "failed to fetch {current}: redirect to an unusable URL: {error}"
1778 ))
1779 })?;
1780 current = next.into();
1781 continue;
1782 }
1783 response = Some((hop, status));
1784 break;
1785 }
1786
1787 let Some((response, status)) = response else {
1788 return Err(fetch_failed(format!(
1789 "failed to fetch {url}: more than {CLI_FETCH_MAX_REDIRECTS} redirects"
1790 )));
1791 };
1792
1793 if !crate::core::remote_policy::is_success_status(status) {
1798 return Err(fetch_failed(format!(
1799 "failed to fetch {current}: HTTP {status}"
1800 )));
1801 }
1802
1803 if let Some(encoding) = response
1804 .headers()
1805 .get("Content-Encoding")
1806 .and_then(|value: &ureq::http::HeaderValue| value.to_str().ok())
1807 .and_then(crate::core::remote_policy::unreadable_content_coding)
1808 {
1809 return Err(fetch_failed(format!(
1810 "failed to fetch {current}: response uses unsupported content-encoding `{encoding}`"
1811 )));
1812 }
1813
1814 if response
1815 .headers()
1816 .get("Content-Length")
1817 .and_then(|v: &ureq::http::HeaderValue| v.to_str().ok())
1818 .and_then(|value: &str| value.parse::<u64>().ok())
1819 .is_some_and(|len| len > max_bytes)
1820 {
1821 return Err(fetch_failed(format!(
1822 "failed to fetch {url}: response exceeds {max_bytes} bytes"
1823 )));
1824 }
1825
1826 let mut reader = response.into_body().into_reader().take(max_bytes + 1);
1829 let mut bytes = Vec::new();
1830 reader
1831 .read_to_end(&mut bytes)
1832 .map_err(|error| fetch_failed(format!("failed to fetch {url}: {error}")))?;
1833
1834 if bytes.len() as u64 > max_bytes {
1835 return Err(fetch_failed(format!(
1836 "failed to fetch {url}: response exceeds {max_bytes} bytes"
1837 )));
1838 }
1839
1840 Ok(bytes)
1841}
1842
1843fn map_transform_error(error: crate::TransformError) -> CliError {
1850 let class = error.class();
1851 let (exit_code, message) = match error {
1852 crate::TransformError::InvalidOptions(reason) => (EXIT_USAGE, reason),
1853 crate::TransformError::InvalidInput(reason) => (EXIT_INPUT, reason),
1854 crate::TransformError::UnsupportedInputMediaType(reason) => (EXIT_INPUT, reason),
1858 crate::TransformError::DecodeFailed(reason)
1859 | crate::TransformError::EncodeFailed(reason)
1860 | crate::TransformError::CapabilityMissing(reason)
1861 | crate::TransformError::LimitExceeded(reason) => (EXIT_TRANSFORM, reason),
1862 ref error @ crate::TransformError::UnsupportedOutputMediaType(_) => {
1865 (EXIT_TRANSFORM, error.to_string())
1866 }
1867 };
1868 classified_error(class, exit_code, &message)
1869}
1870
1871fn convert_error(message: &str) -> CliError {
1876 CliError {
1877 exit_code: EXIT_USAGE,
1878 class: ErrorClass::InvalidRequest,
1879 message: message.to_string(),
1880 usage: Some(convert_usage().to_string()),
1881 hint: Some("run 'truss convert --help' for convert options".to_string()),
1882 }
1883}
1884
1885fn optimize_error(message: &str) -> CliError {
1886 CliError {
1887 exit_code: EXIT_USAGE,
1888 class: ErrorClass::InvalidRequest,
1889 message: message.to_string(),
1890 usage: Some(optimize_usage().to_string()),
1891 hint: Some("run 'truss optimize --help' for optimize options".to_string()),
1892 }
1893}
1894
1895fn sign_error(message: &str) -> CliError {
1896 CliError {
1897 exit_code: EXIT_USAGE,
1898 class: ErrorClass::InvalidRequest,
1899 message: message.to_string(),
1900 usage: Some(sign_usage().to_string()),
1901 hint: Some("run 'truss sign --help' for sign options".to_string()),
1902 }
1903}
1904
1905fn usage_error(message: &str) -> CliError {
1908 classified_error(ErrorClass::InvalidRequest, EXIT_USAGE, message)
1909}
1910
1911fn runtime_error(exit_code: u8, message: &str) -> CliError {
1918 classified_error(ErrorClass::InternalError, exit_code, message)
1919}
1920
1921fn classified_error(class: ErrorClass, exit_code: u8, message: &str) -> CliError {
1923 CliError {
1924 exit_code,
1925 class,
1926 message: message.to_string(),
1927 usage: None,
1928 hint: None,
1929 }
1930}
1931
1932fn stdout_write_error(error: &io::Error) -> CliError {
1938 runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
1939}
1940
1941fn write_error<E>(stderr: &mut E, error: CliError) -> u8
1942where
1943 E: Write,
1944{
1945 let _ = writeln!(
1946 stderr,
1947 "error: {} ({})",
1948 crate::core::single_line(&error.message),
1949 error.class.slug()
1950 );
1951 if let Some(usage) = &error.usage {
1952 let _ = writeln!(stderr, "{usage}");
1953 }
1954 if let Some(hint) = &error.hint {
1955 let _ = writeln!(stderr, "hint: {hint}");
1956 }
1957 error.exit_code
1958}
1959
1960#[cfg(test)]
1965mod tests {
1966 use super::serve::resolve_server_config;
1967 use super::{
1968 Command, ConvertCommand, EXIT_INPUT, EXIT_IO, EXIT_TRANSFORM, EXIT_USAGE, HelpTopic,
1969 InputSource, MAX_REMOTE_BYTES, MAX_REMOTE_WATERMARK_BYTES, OutputTarget, ServeCommand,
1970 SignCommand, flush_stdout, parse_args, parse_optimize_mode, parse_optimizing_mode,
1971 preprocess_args, run_with_io,
1972 };
1973 use crate::{
1974 Fit, MediaType, OptimizeMode, RawArtifact, SignedUrlSource, TransformOptions,
1975 sniff_artifact,
1976 };
1977 use rstest::rstest;
1978 use serial_test::serial;
1979 use std::env;
1980 use std::ffi::OsString;
1981 use std::fs;
1982 use std::io::{self, Cursor, Read, Write};
1983 use std::net::TcpListener;
1984 use std::path::PathBuf;
1985 use std::thread;
1986
1987 fn png_bytes() -> Vec<u8> {
1988 crate::test_support::flat_png(4, 3)
1989 }
1990
1991 fn temp_file_path(name: &str) -> PathBuf {
1992 crate::test_support::unique_temp_path(&format!("truss-{name}")).with_extension("bin")
1993 }
1994
1995 fn temp_dir(name: &str) -> PathBuf {
1996 let path = crate::test_support::unique_temp_path(&format!("truss-{name}"));
1997 fs::create_dir_all(&path).expect("create temp dir");
1998 path
1999 }
2000
2001 fn spawn_http_server(
2002 body: Vec<u8>,
2003 content_type: &'static str,
2004 ) -> (String, thread::JoinHandle<()>) {
2005 let listener = TcpListener::bind("127.0.0.1:0").expect("bind http test server");
2006 let addr = listener.local_addr().expect("server addr");
2007 let url = format!("http://{addr}/image");
2008
2009 let handle = thread::spawn(move || {
2010 let (mut stream, _) = listener.accept().expect("accept connection");
2011 let mut request = [0_u8; 1024];
2012 let _ = stream.read(&mut request);
2013 let header = format!(
2014 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2015 body.len()
2016 );
2017 stream.write_all(header.as_bytes()).expect("write headers");
2018 stream.write_all(&body).expect("write body");
2019 stream.flush().expect("flush response");
2020 });
2021
2022 (url, handle)
2023 }
2024
2025 fn spawn_http_server_with_status(status: &'static str) -> (String, thread::JoinHandle<()>) {
2031 let listener = TcpListener::bind("127.0.0.1:0").expect("bind http test server");
2032 let addr = listener.local_addr().expect("server addr");
2033 let url = format!("http://{addr}/image");
2034 let body = crate::test_support::flat_png(4, 3);
2035
2036 let handle = thread::spawn(move || {
2037 let (mut stream, _) = listener.accept().expect("accept connection");
2038 let mut request = [0_u8; 1024];
2039 let _ = stream.read(&mut request);
2040 let header = format!(
2041 "HTTP/1.1 {status}\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2042 body.len()
2043 );
2044 stream.write_all(header.as_bytes()).expect("write headers");
2045 stream.write_all(&body).expect("write body");
2046 stream.flush().expect("flush response");
2047 });
2048
2049 (url, handle)
2050 }
2051
2052 #[test]
2055 fn bare_invocation_shows_top_level_help() {
2056 let mut stdin = Cursor::new(Vec::<u8>::new());
2057 let mut stdout = Vec::new();
2058 let mut stderr = Vec::new();
2059
2060 let exit_code = run_with_io(
2061 vec!["truss".to_string()],
2062 &mut stdin,
2063 &mut stdout,
2064 &mut stderr,
2065 );
2066
2067 assert_eq!(exit_code, 0);
2068 assert!(stderr.is_empty());
2069 let output = String::from_utf8(stdout).expect("utf8 stdout");
2070 assert!(output.contains("COMMANDS:"));
2071 assert!(output.contains("convert"));
2072 assert!(output.contains("inspect"));
2073 assert!(output.contains("serve"));
2074 assert!(output.contains("sign"));
2075 }
2076
2077 #[test]
2080 fn dash_dash_help_shows_top_level_help() {
2081 let mut stdin = Cursor::new(Vec::<u8>::new());
2082 let mut stdout = Vec::new();
2083 let mut stderr = Vec::new();
2084
2085 let exit_code = run_with_io(
2086 vec!["truss".to_string(), "--help".to_string()],
2087 &mut stdin,
2088 &mut stdout,
2089 &mut stderr,
2090 );
2091
2092 assert_eq!(exit_code, 0);
2093 assert!(stderr.is_empty());
2094 let output = String::from_utf8(stdout).expect("utf8 stdout");
2095 assert!(output.contains("COMMANDS:"));
2096 assert!(output.contains("EXIT CODES:"));
2097 }
2098
2099 #[test]
2102 fn help_command_shows_top_level_help() {
2103 let mut stdin = Cursor::new(Vec::<u8>::new());
2104 let mut stdout = Vec::new();
2105 let mut stderr = Vec::new();
2106
2107 let exit_code = run_with_io(
2108 vec!["truss".to_string(), "help".to_string()],
2109 &mut stdin,
2110 &mut stdout,
2111 &mut stderr,
2112 );
2113
2114 assert_eq!(exit_code, 0);
2115 assert!(stderr.is_empty());
2116 let output = String::from_utf8(stdout).expect("utf8 stdout");
2117 assert!(output.contains("COMMANDS:"));
2118 }
2119
2120 #[test]
2123 fn help_convert_shows_convert_help() {
2124 let mut stdin = Cursor::new(Vec::<u8>::new());
2125 let mut stdout = Vec::new();
2126 let mut stderr = Vec::new();
2127
2128 let exit_code = run_with_io(
2129 vec![
2130 "truss".to_string(),
2131 "help".to_string(),
2132 "convert".to_string(),
2133 ],
2134 &mut stdin,
2135 &mut stdout,
2136 &mut stderr,
2137 );
2138
2139 assert_eq!(exit_code, 0);
2140 assert!(stderr.is_empty());
2141 let output = String::from_utf8(stdout).expect("utf8 stdout");
2142 assert!(output.contains("truss convert"));
2143 assert!(output.contains("--output"));
2144 assert!(output.contains("--width"));
2145 assert!(!output.contains("--bind")); }
2147
2148 #[test]
2149 fn help_optimize_shows_optimize_help() {
2150 let mut stdin = Cursor::new(Vec::<u8>::new());
2151 let mut stdout = Vec::new();
2152 let mut stderr = Vec::new();
2153
2154 let exit_code = run_with_io(
2155 vec![
2156 "truss".to_string(),
2157 "help".to_string(),
2158 "optimize".to_string(),
2159 ],
2160 &mut stdin,
2161 &mut stdout,
2162 &mut stderr,
2163 );
2164
2165 assert_eq!(exit_code, 0);
2166 assert!(stderr.is_empty());
2167 let output = String::from_utf8(stdout).expect("utf8 stdout");
2168 assert!(output.contains("truss optimize"));
2169 assert!(output.contains("--mode"));
2170 assert!(output.contains("--target-quality"));
2171 }
2172
2173 #[test]
2176 fn convert_dash_help_shows_convert_help() {
2177 let mut stdin = Cursor::new(Vec::<u8>::new());
2178 let mut stdout = Vec::new();
2179 let mut stderr = Vec::new();
2180
2181 let exit_code = run_with_io(
2182 vec![
2183 "truss".to_string(),
2184 "convert".to_string(),
2185 "--help".to_string(),
2186 ],
2187 &mut stdin,
2188 &mut stdout,
2189 &mut stderr,
2190 );
2191
2192 assert_eq!(exit_code, 0);
2193 assert!(stderr.is_empty());
2194 let output = String::from_utf8(stdout).expect("utf8 stdout");
2195 assert!(output.contains("truss convert"));
2196 assert!(output.contains("--output"));
2197 }
2198
2199 #[test]
2202 fn serve_dash_help_shows_serve_help() {
2203 let mut stdin = Cursor::new(Vec::<u8>::new());
2204 let mut stdout = Vec::new();
2205 let mut stderr = Vec::new();
2206
2207 let exit_code = run_with_io(
2208 vec![
2209 "truss".to_string(),
2210 "serve".to_string(),
2211 "--help".to_string(),
2212 ],
2213 &mut stdin,
2214 &mut stdout,
2215 &mut stderr,
2216 );
2217
2218 assert_eq!(exit_code, 0);
2219 assert!(stderr.is_empty());
2220 let output = String::from_utf8(stdout).expect("utf8 stdout");
2221 assert!(output.contains("truss serve"));
2222 assert!(output.contains("--bind"));
2223 assert!(output.contains("--storage-root"));
2224 assert!(output.contains("ENVIRONMENT VARIABLES:"));
2225 assert!(!output.contains("--width")); }
2227
2228 #[test]
2231 fn sign_dash_help_shows_sign_help() {
2232 let mut stdin = Cursor::new(Vec::<u8>::new());
2233 let mut stdout = Vec::new();
2234 let mut stderr = Vec::new();
2235
2236 let exit_code = run_with_io(
2237 vec![
2238 "truss".to_string(),
2239 "sign".to_string(),
2240 "--help".to_string(),
2241 ],
2242 &mut stdin,
2243 &mut stdout,
2244 &mut stderr,
2245 );
2246
2247 assert_eq!(exit_code, 0);
2248 assert!(stderr.is_empty());
2249 let output = String::from_utf8(stdout).expect("utf8 stdout");
2250 assert!(output.contains("truss sign"));
2251 assert!(output.contains("--base-url"));
2252 assert!(output.contains("--key-id"));
2253 assert!(output.contains("--expires"));
2254 }
2255
2256 #[test]
2259 fn convert_missing_output_shows_usage_and_hint() {
2260 let mut stdin = Cursor::new(Vec::<u8>::new());
2261 let mut stdout = Vec::new();
2262 let mut stderr = Vec::new();
2263
2264 let exit_code = run_with_io(
2265 vec![
2266 "truss".to_string(),
2267 "convert".to_string(),
2268 "input.png".to_string(),
2269 ],
2270 &mut stdin,
2271 &mut stdout,
2272 &mut stderr,
2273 );
2274
2275 assert_eq!(exit_code, 1);
2276 let output = String::from_utf8(stderr).expect("utf8 stderr");
2277 assert!(output.contains("error:"), "should contain error: {output}");
2278 assert!(output.contains("usage:"), "should contain usage: {output}");
2279 assert!(output.contains("hint:"), "should contain hint: {output}");
2280 }
2281
2282 #[test]
2285 fn inspect_missing_input_shows_usage_and_hint() {
2286 let mut stdin = Cursor::new(Vec::<u8>::new());
2287 let mut stdout = Vec::new();
2288 let mut stderr = Vec::new();
2289
2290 let exit_code = run_with_io(
2291 vec!["truss".to_string(), "inspect".to_string()],
2292 &mut stdin,
2293 &mut stdout,
2294 &mut stderr,
2295 );
2296
2297 assert_eq!(exit_code, 1);
2298 let output = String::from_utf8(stderr).expect("utf8 stderr");
2299 assert!(output.contains("error:"), "should contain error: {output}");
2300 assert!(output.contains("usage:"), "should contain usage: {output}");
2301 assert!(output.contains("hint:"), "should contain hint: {output}");
2302 }
2303
2304 #[test]
2307 fn sign_missing_args_shows_usage_and_hint() {
2308 let mut stdin = Cursor::new(Vec::<u8>::new());
2309 let mut stdout = Vec::new();
2310 let mut stderr = Vec::new();
2311
2312 let exit_code = run_with_io(
2313 vec!["truss".to_string(), "sign".to_string()],
2314 &mut stdin,
2315 &mut stdout,
2316 &mut stderr,
2317 );
2318
2319 assert_eq!(exit_code, 1);
2320 let output = String::from_utf8(stderr).expect("utf8 stderr");
2321 assert!(output.contains("error:"), "should contain error: {output}");
2322 assert!(output.contains("usage:"), "should contain usage: {output}");
2323 assert!(output.contains("hint:"), "should contain hint: {output}");
2324 }
2325
2326 #[test]
2329 fn double_dash_allows_leading_dash_input() {
2330 let result = parse_args(vec![
2331 "truss".to_string(),
2332 "convert".to_string(),
2333 "-o".to_string(),
2334 "out.jpg".to_string(),
2335 "--".to_string(),
2336 "-foo.png".to_string(),
2337 ]);
2338
2339 assert_eq!(
2340 result.unwrap(),
2341 Command::Convert(ConvertCommand {
2342 input: InputSource::Path(PathBuf::from("-foo.png")),
2343 output: OutputTarget::Path(PathBuf::from("out.jpg")),
2344 options: TransformOptions::default(),
2345 watermark_path: None,
2346 watermark_position: None,
2347 watermark_opacity: None,
2348 watermark_margin: None,
2349 })
2350 );
2351 }
2352
2353 #[test]
2360 fn top_level_serve_flags_parse_correctly() {
2361 let command = parse_args(vec![
2362 "truss".to_string(),
2363 "--storage-root".to_string(),
2364 "fixtures".to_string(),
2365 "--public-base-url".to_string(),
2366 "https://assets.example.com".to_string(),
2367 "--allow-insecure-url-sources".to_string(),
2368 ])
2369 .expect("parse implicit serve");
2370
2371 assert_eq!(
2372 command,
2373 Command::Serve(ServeCommand {
2374 bind_addr: None,
2375 storage_root: Some(PathBuf::from("fixtures")),
2376 public_base_url: Some("https://assets.example.com".to_string()),
2377 signed_url_key_id: None,
2378 signed_url_secret: None,
2379 allow_insecure_url_sources: true,
2380 })
2381 );
2382 }
2383
2384 #[test]
2387 fn implicit_convert_still_works() {
2388 let command = parse_args(vec![
2389 "truss".to_string(),
2390 "input.png".to_string(),
2391 "-o".to_string(),
2392 "output.jpg".to_string(),
2393 "--width".to_string(),
2394 "100".to_string(),
2395 "--fit".to_string(),
2396 "contain".to_string(),
2397 ])
2398 .expect("parse implicit convert");
2399
2400 assert_eq!(
2401 command,
2402 Command::Convert(ConvertCommand {
2403 input: InputSource::Path(PathBuf::from("input.png")),
2404 output: OutputTarget::Path(PathBuf::from("output.jpg")),
2405 options: TransformOptions {
2406 width: Some(100),
2407 fit: Some(Fit::Contain),
2408 ..TransformOptions::default()
2409 },
2410 watermark_path: None,
2411 watermark_position: None,
2412 watermark_opacity: None,
2413 watermark_margin: None,
2414 })
2415 );
2416 }
2417
2418 #[test]
2421 fn exit_code_help_is_zero() {
2422 let mut stdin = Cursor::new(Vec::<u8>::new());
2423 let mut stdout = Vec::new();
2424 let mut stderr = Vec::new();
2425
2426 let code = run_with_io(
2427 vec!["truss".to_string(), "--help".to_string()],
2428 &mut stdin,
2429 &mut stdout,
2430 &mut stderr,
2431 );
2432 assert_eq!(code, 0);
2433 }
2434
2435 #[test]
2436 fn exit_code_usage_error_is_one() {
2437 let mut stdin = Cursor::new(Vec::<u8>::new());
2438 let mut stdout = Vec::new();
2439 let mut stderr = Vec::new();
2440
2441 let code = run_with_io(
2442 vec![
2443 "truss".to_string(),
2444 "convert".to_string(),
2445 "input.png".to_string(),
2446 ],
2447 &mut stdin,
2448 &mut stdout,
2449 &mut stderr,
2450 );
2451 assert_eq!(code, 1);
2452 }
2453
2454 #[test]
2455 fn exit_code_io_error() {
2456 let mut stdin = Cursor::new(Vec::<u8>::new());
2457 let mut stdout = Vec::new();
2458 let mut stderr = Vec::new();
2459
2460 let code = run_with_io(
2461 vec![
2462 "truss".to_string(),
2463 "inspect".to_string(),
2464 "missing-file.png".to_string(),
2465 ],
2466 &mut stdin,
2467 &mut stdout,
2468 &mut stderr,
2469 );
2470 assert_eq!(code, 2);
2471 }
2472
2473 #[test]
2474 fn exit_code_input_error() {
2475 let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
2476 let mut stdout = Vec::new();
2477 let mut stderr = Vec::new();
2478
2479 let code = run_with_io(
2480 vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
2481 &mut stdin,
2482 &mut stdout,
2483 &mut stderr,
2484 );
2485 assert_eq!(code, 3);
2486 }
2487
2488 #[test]
2493 fn map_transform_error_matches_the_class_table() {
2494 const PROBLEM_DOCS: &str = include_str!("../../../docs/problems.md");
2495 let problem_docs = PROBLEM_DOCS.replace('\r', "");
2498 let cases: [(crate::TransformError, &str, u8); 8] = [
2499 (
2500 crate::TransformError::InvalidOptions("x".into()),
2501 "invalid-options",
2502 EXIT_USAGE,
2503 ),
2504 (
2505 crate::TransformError::InvalidInput("x".into()),
2506 "invalid-input",
2507 EXIT_INPUT,
2508 ),
2509 (
2510 crate::TransformError::UnsupportedInputMediaType("x".into()),
2511 "unsupported-input-media-type",
2512 EXIT_INPUT,
2513 ),
2514 (
2515 crate::TransformError::DecodeFailed("x".into()),
2516 "decode-failed",
2517 EXIT_TRANSFORM,
2518 ),
2519 (
2520 crate::TransformError::EncodeFailed("x".into()),
2521 "encode-failed",
2522 EXIT_TRANSFORM,
2523 ),
2524 (
2525 crate::TransformError::CapabilityMissing("x".into()),
2526 "capability-missing",
2527 EXIT_TRANSFORM,
2528 ),
2529 (
2530 crate::TransformError::LimitExceeded("x".into()),
2531 "limit-exceeded",
2532 EXIT_TRANSFORM,
2533 ),
2534 (
2535 crate::TransformError::UnsupportedOutputMediaType(MediaType::Gif),
2536 "unsupported-output-media-type",
2537 EXIT_TRANSFORM,
2538 ),
2539 ];
2540
2541 for (error, slug, exit_code) in cases {
2542 let mapped = super::map_transform_error(error.clone());
2543 assert_eq!(mapped.class.slug(), slug, "{error:?}");
2544 assert_eq!(mapped.exit_code, exit_code, "{error:?}");
2545 assert!(
2546 problem_docs.contains(&format!("### {slug}\n")),
2547 "docs/problems.md should document the {slug} class"
2548 );
2549 }
2550 }
2551
2552 #[test]
2555 fn stderr_names_the_class_after_the_message() {
2556 let mut stderr = Vec::new();
2557 let code = super::write_error(
2558 &mut stderr,
2559 super::map_transform_error(crate::TransformError::LimitExceeded(
2560 "output image would have 400000000 pixels, limit is 67108864".to_string(),
2561 )),
2562 );
2563
2564 assert_eq!(code, EXIT_TRANSFORM);
2565 assert_eq!(
2566 String::from_utf8(stderr).expect("utf-8 stderr"),
2567 "error: output image would have 400000000 pixels, limit is 67108864 (limit-exceeded)\n"
2568 );
2569 }
2570
2571 #[test]
2574 fn stderr_names_the_class_on_a_usage_error() {
2575 let mut stderr = Vec::new();
2576 let code = super::write_error(&mut stderr, super::sign_error("'sign' requires --key-id"));
2577
2578 assert_eq!(code, EXIT_USAGE);
2579 let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2580 assert!(
2581 rendered.starts_with("error: 'sign' requires --key-id (invalid-request)\n"),
2582 "{rendered}"
2583 );
2584 assert!(rendered.ends_with("hint: run 'truss sign --help' for sign options\n"));
2585 }
2586
2587 #[test]
2590 fn missing_input_reports_the_not_found_class() {
2591 let mut stdin = Cursor::new(Vec::<u8>::new());
2592 let mut stdout = Vec::new();
2593 let mut stderr = Vec::new();
2594
2595 let code = run_with_io(
2596 vec![
2597 "truss".to_string(),
2598 "inspect".to_string(),
2599 "missing-file.png".to_string(),
2600 ],
2601 &mut stdin,
2602 &mut stdout,
2603 &mut stderr,
2604 );
2605
2606 assert_eq!(code, 2);
2607 let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2608 assert!(rendered.contains("(not-found)"), "{rendered}");
2609 }
2610
2611 #[test]
2615 fn a_decode_failure_from_the_sniff_is_a_transform_error() {
2616 let truncated = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
2618 for command in ["inspect", "convert"] {
2619 let mut stdin = Cursor::new(truncated.clone());
2620 let mut stdout = Vec::new();
2621 let mut stderr = Vec::new();
2622
2623 let mut args = vec!["truss".to_string(), command.to_string(), "-".to_string()];
2624 if command == "convert" {
2625 args.push("--output".to_string());
2626 args.push("-".to_string());
2627 }
2628 let code = run_with_io(args, &mut stdin, &mut stdout, &mut stderr);
2629
2630 let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2631 assert_eq!(code, EXIT_TRANSFORM, "{command}: {rendered}");
2632 assert!(
2633 rendered.contains("(decode-failed)"),
2634 "{command}: {rendered}"
2635 );
2636 }
2637 }
2638
2639 #[test]
2642 fn unknown_subcommand_exits_with_usage_error() {
2643 let mut stdin = Cursor::new(Vec::<u8>::new());
2644 let mut stdout = Vec::new();
2645 let mut stderr = Vec::new();
2646
2647 let exit_code = run_with_io(
2648 vec!["truss".to_string(), "converrt".to_string()],
2649 &mut stdin,
2650 &mut stdout,
2651 &mut stderr,
2652 );
2653
2654 assert_eq!(exit_code, 1);
2655 let output = String::from_utf8(stderr).expect("utf8 stderr");
2656 assert!(output.contains("error:"), "should contain error: {output}");
2657 }
2658
2659 #[test]
2662 fn inspect_dash_help_shows_inspect_help() {
2663 let mut stdin = Cursor::new(Vec::<u8>::new());
2664 let mut stdout = Vec::new();
2665 let mut stderr = Vec::new();
2666
2667 let exit_code = run_with_io(
2668 vec![
2669 "truss".to_string(),
2670 "inspect".to_string(),
2671 "--help".to_string(),
2672 ],
2673 &mut stdin,
2674 &mut stdout,
2675 &mut stderr,
2676 );
2677
2678 assert_eq!(exit_code, 0);
2679 let output = String::from_utf8(stdout).expect("utf8 stdout");
2680 assert!(output.contains("truss inspect"));
2681 assert!(output.contains("--url"));
2682 }
2683
2684 #[test]
2687 fn help_inspect_shows_inspect_help() {
2688 let result = parse_args(vec![
2689 "truss".to_string(),
2690 "help".to_string(),
2691 "inspect".to_string(),
2692 ]);
2693 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Inspect));
2694 }
2695
2696 #[test]
2706 fn help_serve_keeps_every_environment_variable_in_one_column() {
2707 let help = super::help_serve();
2708 let misaligned: Vec<&str> = help
2709 .lines()
2710 .skip_while(|line| !line.starts_with("ENVIRONMENT VARIABLES:"))
2711 .filter(|line| {
2712 let trimmed = line.trim_start();
2713 trimmed.starts_with("TRUSS_")
2714 || trimmed.starts_with("AWS_")
2715 || trimmed.starts_with("AZURE_")
2716 || trimmed.starts_with("GOOGLE_")
2717 })
2718 .filter(|line| !line.starts_with(" ") || line.starts_with(" "))
2719 .collect();
2720
2721 assert!(
2722 misaligned.is_empty(),
2723 "every environment variable row is indented two spaces, found: {misaligned:#?}"
2724 );
2725 }
2726
2727 #[test]
2738 fn the_serve_help_names_every_documented_setting_that_is_not_behind_a_feature() {
2739 let reference = include_str!("../../../docs/configuration.md");
2740 let help = super::help_serve();
2741
2742 let mut documented: Vec<&str> = Vec::new();
2743 let mut in_backend_section = false;
2744 for line in reference.lines() {
2745 if let Some(heading) = line.strip_prefix("## ") {
2746 in_backend_section = matches!(heading, "S3" | "GCS" | "Azure Blob Storage");
2747 }
2748 if in_backend_section {
2749 continue;
2750 }
2751 let Some(rest) = line.strip_prefix("| `TRUSS_") else {
2752 continue;
2753 };
2754 let Some(end) = rest.find('`') else { continue };
2755 let name = &line[3..3 + "TRUSS_".len() + end];
2756 if !documented.contains(&name) {
2757 documented.push(name);
2758 }
2759 }
2760 assert!(
2761 documented.len() > 30,
2762 "the reference was read wrong: {documented:?}"
2763 );
2764
2765 let missing: Vec<&&str> = documented
2766 .iter()
2767 .filter(|name| !help.contains(*name))
2768 .collect();
2769 assert!(
2770 missing.is_empty(),
2771 "documented settings the serve help does not name: {missing:?}"
2772 );
2773
2774 let undocumented: Vec<&str> = help
2775 .lines()
2776 .filter_map(|line| line.split_whitespace().next())
2777 .map(|word| word.split('=').next().unwrap_or(word))
2780 .filter(|word| word.starts_with("TRUSS_"))
2781 .filter(|word| !reference.contains(&format!("`{word}`")))
2782 .collect();
2783 assert!(
2784 undocumented.is_empty(),
2785 "settings the serve help names and docs/configuration.md does not: {undocumented:?}"
2786 );
2787 }
2788
2789 #[test]
2790 fn help_serve_shows_serve_help() {
2791 let result = parse_args(vec![
2792 "truss".to_string(),
2793 "help".to_string(),
2794 "serve".to_string(),
2795 ]);
2796 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Serve));
2797 }
2798
2799 #[test]
2802 fn help_validate_shows_validate_help() {
2803 let result = parse_args(vec![
2804 "truss".to_string(),
2805 "help".to_string(),
2806 "validate".to_string(),
2807 ]);
2808 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
2809 }
2810
2811 #[test]
2812 fn parse_args_validate() {
2813 let result =
2814 parse_args(vec!["truss".to_string(), "validate".to_string()]).expect("parse validate");
2815 assert_eq!(result, Command::Validate);
2816 }
2817
2818 #[test]
2819 fn validate_help_flag() {
2820 let result = parse_args(vec![
2821 "truss".to_string(),
2822 "validate".to_string(),
2823 "--help".to_string(),
2824 ]);
2825 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
2826 }
2827
2828 #[test]
2829 #[serial]
2830 fn validate_invalid_config() {
2831 unsafe { env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "invalid") };
2833 let mut stdout = Vec::new();
2834 let result = super::serve::execute_validate(&mut stdout);
2835 unsafe { env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS") };
2836 assert!(result.is_err());
2837 }
2838
2839 #[test]
2840 #[serial]
2841 fn validate_valid_config() {
2842 let dir = tempfile::tempdir().expect("create temp dir");
2843 let mut stdout = Vec::new();
2844 unsafe { env::set_var("TRUSS_STORAGE_ROOT", dir.path().to_str().unwrap()) };
2846 let result = super::serve::execute_validate(&mut stdout);
2847 unsafe { env::remove_var("TRUSS_STORAGE_ROOT") };
2848 assert!(result.is_ok());
2849 let output = String::from_utf8(stdout).expect("valid utf-8");
2850 assert!(output.contains("configuration is valid"));
2851 assert!(output.contains("storage root:"));
2852 }
2853
2854 #[test]
2857 fn help_sign_shows_sign_help() {
2858 let result = parse_args(vec![
2859 "truss".to_string(),
2860 "help".to_string(),
2861 "sign".to_string(),
2862 ]);
2863 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Sign));
2864 }
2865
2866 #[test]
2869 fn dash_h_shows_top_level_help() {
2870 let result = parse_args(vec!["truss".to_string(), "-h".to_string()]);
2871 assert_eq!(result.unwrap(), Command::Help(HelpTopic::TopLevel));
2872 }
2873
2874 #[test]
2877 fn parse_args_supports_serve_bind() {
2878 let command = parse_args(vec![
2879 "truss".to_string(),
2880 "serve".to_string(),
2881 "--bind".to_string(),
2882 "127.0.0.1:9000".to_string(),
2883 ])
2884 .expect("parse serve bind");
2885
2886 assert_eq!(
2887 command,
2888 Command::Serve(ServeCommand {
2889 bind_addr: Some("127.0.0.1:9000".to_string()),
2890 storage_root: None,
2891 public_base_url: None,
2892 signed_url_key_id: None,
2893 signed_url_secret: None,
2894 allow_insecure_url_sources: false,
2895 })
2896 );
2897 }
2898
2899 #[test]
2900 fn parse_args_supports_serve_runtime_options() {
2901 let command = parse_args(vec![
2902 "truss".to_string(),
2903 "serve".to_string(),
2904 "--storage-root".to_string(),
2905 "fixtures".to_string(),
2906 "--public-base-url".to_string(),
2907 "https://assets.example.com".to_string(),
2908 "--signed-url-key-id".to_string(),
2909 "public-dev".to_string(),
2910 "--signed-url-secret".to_string(),
2911 "secret-value".to_string(),
2912 "--allow-insecure-url-sources".to_string(),
2913 ])
2914 .expect("parse serve runtime options");
2915
2916 assert_eq!(
2917 command,
2918 Command::Serve(ServeCommand {
2919 bind_addr: None,
2920 storage_root: Some(PathBuf::from("fixtures")),
2921 public_base_url: Some("https://assets.example.com".to_string()),
2922 signed_url_key_id: Some("public-dev".to_string()),
2923 signed_url_secret: Some("secret-value".to_string()),
2924 allow_insecure_url_sources: true,
2925 })
2926 );
2927 }
2928
2929 #[test]
2933 fn a_negative_crop_origin_is_answered_by_the_crop_validator_however_it_is_written() {
2934 let spaced = parse_args(vec![
2935 "truss".to_string(),
2936 "convert".to_string(),
2937 "in.png".to_string(),
2938 "-o".to_string(),
2939 "out.png".to_string(),
2940 "--crop".to_string(),
2941 "-1,0,2,2".to_string(),
2942 ])
2943 .expect_err("a negative crop origin is refused");
2944
2945 let assigned = parse_args(vec![
2946 "truss".to_string(),
2947 "convert".to_string(),
2948 "in.png".to_string(),
2949 "-o".to_string(),
2950 "out.png".to_string(),
2951 "--crop=-1,0,2,2".to_string(),
2952 ])
2953 .expect_err("a negative crop origin is refused");
2954
2955 assert_eq!(spaced.message, assigned.message);
2956 assert!(
2957 spaced
2958 .message
2959 .contains("crop x must be a non-negative integer, got '-1'"),
2960 "message: {}",
2961 spaced.message,
2962 );
2963 }
2964
2965 #[test]
2967 fn sign_answers_a_negative_crop_origin_with_the_crop_validator() {
2968 let error = parse_args(vec![
2969 "truss".to_string(),
2970 "sign".to_string(),
2971 "--path".to_string(),
2972 "a.png".to_string(),
2973 "--crop".to_string(),
2974 "-1,0,2,2".to_string(),
2975 ])
2976 .expect_err("a negative crop origin is refused");
2977
2978 assert!(
2979 error
2980 .message
2981 .contains("crop x must be a non-negative integer, got '-1'"),
2982 "message: {}",
2983 error.message,
2984 );
2985 }
2986
2987 #[test]
2991 fn an_out_of_range_sigma_is_invalid_options_and_a_non_number_is_invalid_request() {
2992 for flag in ["--blur", "--sharpen"] {
2993 for value in ["0", "100.1"] {
2994 let command = parse_args(vec![
2995 "truss".to_string(),
2996 "convert".to_string(),
2997 "in.png".to_string(),
2998 "-o".to_string(),
2999 "out.png".to_string(),
3000 flag.to_string(),
3001 value.to_string(),
3002 ])
3003 .unwrap_or_else(|error| {
3004 panic!(
3005 "{flag} {value} should reach the transform: {}",
3006 error.message
3007 )
3008 });
3009 let options = match command {
3010 Command::Convert(convert) => convert.options,
3011 _ => panic!("expected convert command"),
3012 };
3013 let error = options
3014 .normalize(crate::MediaType::Png)
3015 .expect_err("a sigma outside the range is refused");
3016 assert_eq!(
3017 error.class(),
3018 crate::core::error_class::ErrorClass::InvalidOptions,
3019 "{flag} {value}"
3020 );
3021 }
3022
3023 let error = parse_args(vec![
3024 "truss".to_string(),
3025 "convert".to_string(),
3026 "in.png".to_string(),
3027 "-o".to_string(),
3028 "out.png".to_string(),
3029 flag.to_string(),
3030 "abc".to_string(),
3031 ])
3032 .expect_err("a sigma that is not a number is refused");
3033 assert_eq!(
3034 error.class,
3035 crate::core::error_class::ErrorClass::InvalidRequest,
3036 "{flag}"
3037 );
3038 }
3039 }
3040
3041 #[test]
3042 fn parse_args_rejects_partial_signed_url_credentials() {
3043 let error = parse_args(vec![
3044 "truss".to_string(),
3045 "serve".to_string(),
3046 "--signed-url-key-id".to_string(),
3047 "public-dev".to_string(),
3048 ])
3049 .expect("parse serve args first");
3050
3051 let error = match error {
3052 Command::Serve(command) => {
3053 resolve_server_config(command).expect_err("partial credentials should fail")
3054 }
3055 _ => panic!("expected serve command"),
3056 };
3057
3058 assert_eq!(error.exit_code, 1);
3059 assert!(
3060 error
3061 .message
3062 .contains("--signed-url-key-id and --signed-url-secret must be provided together")
3063 );
3064 }
3065
3066 #[test]
3067 fn parse_args_rejects_invalid_public_base_url() {
3068 let error = parse_args(vec![
3069 "truss".to_string(),
3070 "serve".to_string(),
3071 "--public-base-url".to_string(),
3072 "ftp://assets.example.com".to_string(),
3073 ])
3074 .expect_err("invalid public base URL should fail");
3075
3076 assert_eq!(error.exit_code, 1);
3077 assert!(
3078 error
3079 .message
3080 .contains("requires an http:// or https:// URL"),
3081 "message: {}",
3082 error.message,
3083 );
3084 }
3085
3086 #[test]
3087 #[serial]
3088 fn resolve_server_config_applies_serve_overrides() {
3089 let storage_root = temp_dir("serve-config");
3090 let expected_storage_root = storage_root.canonicalize().expect("canonicalize temp dir");
3091 let config = resolve_server_config(ServeCommand {
3092 bind_addr: Some("127.0.0.1:0".to_string()),
3093 storage_root: Some(storage_root.clone()),
3094 public_base_url: Some("https://assets.example.com".to_string()),
3095 signed_url_key_id: Some("public-dev".to_string()),
3096 signed_url_secret: Some("secret-value".to_string()),
3097 allow_insecure_url_sources: true,
3098 })
3099 .expect("resolve server config");
3100
3101 let _ = fs::remove_dir_all(storage_root);
3102
3103 assert_eq!(config.storage_root, expected_storage_root);
3104 assert_eq!(
3105 config.public_base_url.as_deref(),
3106 Some("https://assets.example.com")
3107 );
3108 assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
3109 assert_eq!(config.signed_url_secret.as_deref(), Some("secret-value"));
3110 assert!(config.allow_insecure_url_sources);
3111 }
3112
3113 #[test]
3114 fn parse_args_supports_inspect_path() {
3115 let command = parse_args(vec![
3116 "truss".to_string(),
3117 "inspect".to_string(),
3118 "input.png".to_string(),
3119 ])
3120 .expect("parse inspect path");
3121
3122 assert_eq!(
3123 command,
3124 Command::Inspect(super::InspectCommand {
3125 input: InputSource::Path(PathBuf::from("input.png"))
3126 })
3127 );
3128 }
3129
3130 #[test]
3131 fn parse_args_supports_inspect_url() {
3132 let command = parse_args(vec![
3133 "truss".to_string(),
3134 "inspect".to_string(),
3135 "--url".to_string(),
3136 "http://example.com/image.png".to_string(),
3137 ])
3138 .expect("parse inspect url");
3139
3140 assert_eq!(
3141 command,
3142 Command::Inspect(super::InspectCommand {
3143 input: InputSource::Url("http://example.com/image.png".to_string())
3144 })
3145 );
3146 }
3147
3148 #[test]
3149 fn parse_args_supports_convert_path_and_output() {
3150 let command = parse_args(vec![
3151 "truss".to_string(),
3152 "convert".to_string(),
3153 "input.png".to_string(),
3154 "-o".to_string(),
3155 "output.jpg".to_string(),
3156 "--width".to_string(),
3157 "100".to_string(),
3158 "--fit".to_string(),
3159 "contain".to_string(),
3160 ])
3161 .expect("parse convert");
3162
3163 assert_eq!(
3164 command,
3165 Command::Convert(ConvertCommand {
3166 input: InputSource::Path(PathBuf::from("input.png")),
3167 output: OutputTarget::Path(PathBuf::from("output.jpg")),
3168 options: TransformOptions {
3169 width: Some(100),
3170 fit: Some(Fit::Contain),
3171 ..TransformOptions::default()
3172 },
3173 watermark_path: None,
3174 watermark_position: None,
3175 watermark_opacity: None,
3176 watermark_margin: None,
3177 })
3178 );
3179 }
3180
3181 #[test]
3182 fn parse_args_supports_optimize_subcommand() {
3183 let command = parse_args(vec![
3184 "truss".to_string(),
3185 "optimize".to_string(),
3186 "input.png".to_string(),
3187 "-o".to_string(),
3188 "output.png".to_string(),
3189 "--mode".to_string(),
3190 "lossless".to_string(),
3191 ])
3192 .expect("parse optimize");
3193
3194 assert_eq!(
3195 command,
3196 Command::Optimize(ConvertCommand {
3197 input: InputSource::Path(PathBuf::from("input.png")),
3198 output: OutputTarget::Path(PathBuf::from("output.png")),
3199 options: TransformOptions {
3200 optimize: OptimizeMode::Lossless,
3201 ..TransformOptions::default()
3202 },
3203 watermark_path: None,
3204 watermark_position: None,
3205 watermark_opacity: None,
3206 watermark_margin: None,
3207 })
3208 );
3209 }
3210
3211 #[test]
3216 fn optimize_refuses_a_mode_that_does_not_optimize() {
3217 let message =
3218 parse_optimizing_mode("none").expect_err("`none` is not an optimization mode");
3219 assert!(
3220 message.contains("truss convert"),
3221 "the refusal should name the command that does a plain re-encode: {message}"
3222 );
3223
3224 for mode in ["auto", "lossless", "lossy"] {
3225 assert!(
3226 parse_optimizing_mode(mode).is_ok(),
3227 "{mode} is an optimization mode"
3228 );
3229 }
3230
3231 assert_eq!(parse_optimize_mode("none"), Ok(OptimizeMode::None));
3233 }
3234
3235 #[test]
3236 fn parse_args_optimize_defaults_to_auto_mode() {
3237 let command = parse_args(vec![
3238 "truss".to_string(),
3239 "optimize".to_string(),
3240 "input.png".to_string(),
3241 "-o".to_string(),
3242 "output.webp".to_string(),
3243 ])
3244 .expect("parse optimize");
3245
3246 assert_eq!(
3247 command,
3248 Command::Optimize(ConvertCommand {
3249 input: InputSource::Path(PathBuf::from("input.png")),
3250 output: OutputTarget::Path(PathBuf::from("output.webp")),
3251 options: TransformOptions {
3252 optimize: OptimizeMode::Auto,
3253 ..TransformOptions::default()
3254 },
3255 watermark_path: None,
3256 watermark_position: None,
3257 watermark_opacity: None,
3258 watermark_margin: None,
3259 })
3260 );
3261 }
3262
3263 #[test]
3264 fn parse_args_rejects_non_optimizable_optimize_format() {
3265 let error = parse_args(vec![
3266 "truss".to_string(),
3267 "optimize".to_string(),
3268 "input.png".to_string(),
3269 "-o".to_string(),
3270 "output.svg".to_string(),
3271 "--format".to_string(),
3272 "svg".to_string(),
3273 ])
3274 .expect_err("svg optimize output should be rejected");
3275
3276 assert!(
3277 error
3278 .message
3279 .contains("optimization is not supported for svg output")
3280 );
3281 }
3282
3283 #[test]
3284 fn parse_args_supports_convert_url_and_output() {
3285 let command = parse_args(vec![
3286 "truss".to_string(),
3287 "convert".to_string(),
3288 "--url".to_string(),
3289 "http://example.com/image.png".to_string(),
3290 "-o".to_string(),
3291 "output.jpg".to_string(),
3292 ])
3293 .expect("parse convert url");
3294
3295 assert_eq!(
3296 command,
3297 Command::Convert(ConvertCommand {
3298 input: InputSource::Url("http://example.com/image.png".to_string()),
3299 output: OutputTarget::Path(PathBuf::from("output.jpg")),
3300 options: TransformOptions::default(),
3301 watermark_path: None,
3302 watermark_position: None,
3303 watermark_opacity: None,
3304 watermark_margin: None,
3305 })
3306 );
3307 }
3308
3309 #[test]
3310 fn parse_args_supports_sign_for_path_sources() {
3311 let command = parse_args(vec![
3312 "truss".to_string(),
3313 "sign".to_string(),
3314 "--base-url".to_string(),
3315 "https://cdn.example.com".to_string(),
3316 "--path".to_string(),
3317 "/image.png".to_string(),
3318 "--key-id".to_string(),
3319 "public-dev".to_string(),
3320 "--secret".to_string(),
3321 "secret-value".to_string(),
3322 "--expires".to_string(),
3323 "4102444800".to_string(),
3324 "--format".to_string(),
3325 "jpeg".to_string(),
3326 ])
3327 .expect("parse sign path");
3328
3329 assert_eq!(
3330 command,
3331 Command::Sign(SignCommand {
3332 base_url: "https://cdn.example.com".to_string(),
3333 method: super::SignedMethod::Get,
3334 source: SignedUrlSource::Path {
3335 path: "/image.png".to_string(),
3336 version: None
3337 },
3338 key_id: "public-dev".to_string(),
3339 secret: "secret-value".to_string(),
3340 expires: 4_102_444_800,
3341 options: TransformOptions {
3342 format: Some(MediaType::Jpeg),
3343 ..TransformOptions::default()
3344 },
3345 watermark_url: None,
3346 watermark_position: None,
3347 watermark_opacity: None,
3348 watermark_margin: None,
3349 preset: None,
3350 })
3351 );
3352 }
3353
3354 #[test]
3355 fn parse_args_supports_sign_for_url_sources() {
3356 let command = parse_args(vec![
3357 "truss".to_string(),
3358 "sign".to_string(),
3359 "--base-url".to_string(),
3360 "https://cdn.example.com".to_string(),
3361 "--url".to_string(),
3362 "https://origin.example.com/image.png".to_string(),
3363 "--version".to_string(),
3364 "v2".to_string(),
3365 "--key-id".to_string(),
3366 "public-dev".to_string(),
3367 "--secret".to_string(),
3368 "secret-value".to_string(),
3369 "--expires".to_string(),
3370 "4102444800".to_string(),
3371 "--width".to_string(),
3372 "120".to_string(),
3373 ])
3374 .expect("parse sign url");
3375
3376 assert_eq!(
3377 command,
3378 Command::Sign(SignCommand {
3379 base_url: "https://cdn.example.com".to_string(),
3380 method: super::SignedMethod::Get,
3381 source: SignedUrlSource::Url {
3382 url: "https://origin.example.com/image.png".to_string(),
3383 version: Some("v2".to_string())
3384 },
3385 key_id: "public-dev".to_string(),
3386 secret: "secret-value".to_string(),
3387 expires: 4_102_444_800,
3388 options: TransformOptions {
3389 width: Some(120),
3390 ..TransformOptions::default()
3391 },
3392 watermark_url: None,
3393 watermark_position: None,
3394 watermark_opacity: None,
3395 watermark_margin: None,
3396 preset: None,
3397 })
3398 );
3399 }
3400
3401 #[test]
3402 fn parse_args_supports_sign_with_preset() {
3403 let command = parse_args(vec![
3404 "truss".to_string(),
3405 "sign".to_string(),
3406 "--base-url".to_string(),
3407 "https://cdn.example.com".to_string(),
3408 "--path".to_string(),
3409 "/hero.jpg".to_string(),
3410 "--key-id".to_string(),
3411 "mykey".to_string(),
3412 "--secret".to_string(),
3413 "s3cret".to_string(),
3414 "--expires".to_string(),
3415 "1700000000".to_string(),
3416 "--preset".to_string(),
3417 "thumbnail".to_string(),
3418 ])
3419 .expect("parse sign with preset");
3420
3421 match command {
3422 Command::Sign(s) => assert_eq!(s.preset.as_deref(), Some("thumbnail")),
3423 _ => panic!("expected Sign command"),
3424 }
3425 }
3426
3427 #[test]
3428 fn parse_args_rejects_missing_convert_output() {
3429 let error = parse_args(vec![
3430 "truss".to_string(),
3431 "convert".to_string(),
3432 "input.png".to_string(),
3433 ])
3434 .expect_err("missing output should fail");
3435
3436 assert_eq!(error.exit_code, 1);
3437 assert!(error.message.contains("requires -o"));
3438 }
3439
3440 #[test]
3441 fn parse_args_supports_inspect_https_url() {
3442 let command = parse_args(vec![
3443 "truss".to_string(),
3444 "inspect".to_string(),
3445 "--url".to_string(),
3446 "https://example.com/image.png".to_string(),
3447 ])
3448 .expect("inspect https url should parse");
3449
3450 assert_eq!(
3451 command,
3452 Command::Inspect(super::InspectCommand {
3453 input: InputSource::Url("https://example.com/image.png".to_string())
3454 })
3455 );
3456 }
3457
3458 #[test]
3459 fn parse_args_rejects_invalid_convert_url_scheme() {
3460 let error = parse_args(vec![
3461 "truss".to_string(),
3462 "convert".to_string(),
3463 "--url".to_string(),
3464 "ftp://example.com/image.png".to_string(),
3465 "-o".to_string(),
3466 "out.png".to_string(),
3467 ])
3468 .expect_err("convert invalid scheme should fail");
3469
3470 assert_eq!(error.exit_code, 1);
3471 assert!(
3472 error
3473 .message
3474 .contains("requires an http:// or https:// URL")
3475 );
3476 }
3477
3478 #[test]
3479 fn run_with_io_converts_without_explicit_subcommand() {
3480 let input_path = temp_file_path("implicit-convert-input");
3481 let output_path = temp_file_path("implicit-convert-output").with_extension("jpg");
3482 fs::write(&input_path, png_bytes()).expect("write input file");
3483
3484 let mut stdin = Cursor::new(Vec::<u8>::new());
3485 let mut stdout = Vec::new();
3486 let mut stderr = Vec::new();
3487
3488 let exit_code = run_with_io(
3489 vec![
3490 "truss".to_string(),
3491 input_path.display().to_string(),
3492 "-o".to_string(),
3493 output_path.display().to_string(),
3494 ],
3495 &mut stdin,
3496 &mut stdout,
3497 &mut stderr,
3498 );
3499
3500 let output_bytes = fs::read(&output_path).expect("read output file");
3501 let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3502
3503 let _ = fs::remove_file(&input_path);
3504 let _ = fs::remove_file(&output_path);
3505
3506 assert_eq!(exit_code, 0);
3507 assert!(stdout.is_empty());
3508 assert!(stderr.is_empty());
3509 assert_eq!(artifact.media_type, MediaType::Jpeg);
3510 }
3511
3512 #[test]
3513 fn run_with_io_inspects_a_file() {
3514 let path = temp_file_path("inspect");
3515 fs::write(&path, png_bytes()).expect("write temp file");
3516
3517 let mut stdin = Cursor::new(Vec::<u8>::new());
3518 let mut stdout = Vec::new();
3519 let mut stderr = Vec::new();
3520
3521 let exit_code = run_with_io(
3522 vec![
3523 "truss".to_string(),
3524 "inspect".to_string(),
3525 path.display().to_string(),
3526 ],
3527 &mut stdin,
3528 &mut stdout,
3529 &mut stderr,
3530 );
3531
3532 let _ = fs::remove_file(&path);
3533
3534 assert_eq!(exit_code, 0);
3535 assert!(stderr.is_empty());
3536
3537 let output = String::from_utf8(stdout).expect("utf8 stdout");
3538 assert!(output.contains("\"format\": \"png\""));
3539 assert!(output.contains("\"mime\": \"image/png\""));
3540 assert!(output.contains("\"width\": 4"));
3541 assert!(output.contains("\"height\": 3"));
3542 assert!(output.contains("\"hasAlpha\": true"));
3543 assert!(output.contains("\"isAnimated\": false"));
3544 }
3545
3546 #[test]
3547 fn run_with_io_inspects_a_url() {
3548 let (url, handle) = spawn_http_server(png_bytes(), "image/png");
3549 let mut stdin = Cursor::new(Vec::<u8>::new());
3550 let mut stdout = Vec::new();
3551 let mut stderr = Vec::new();
3552
3553 let exit_code = run_with_io(
3554 vec![
3555 "truss".to_string(),
3556 "inspect".to_string(),
3557 "--url".to_string(),
3558 url,
3559 ],
3560 &mut stdin,
3561 &mut stdout,
3562 &mut stderr,
3563 );
3564
3565 handle.join().expect("join server thread");
3566
3567 assert_eq!(exit_code, 0);
3568 assert!(stderr.is_empty());
3569 assert!(
3570 String::from_utf8(stdout)
3571 .expect("utf8 stdout")
3572 .contains("\"format\": \"png\"")
3573 );
3574 }
3575
3576 #[test]
3577 fn run_with_io_converts_a_file_and_infers_output_format_from_extension() {
3578 let input_path = temp_file_path("convert-input");
3579 let output_path = temp_file_path("convert-output").with_extension("jpg");
3580 fs::write(&input_path, png_bytes()).expect("write input file");
3581
3582 let mut stdin = Cursor::new(Vec::<u8>::new());
3583 let mut stdout = Vec::new();
3584 let mut stderr = Vec::new();
3585
3586 let exit_code = run_with_io(
3587 vec![
3588 "truss".to_string(),
3589 "convert".to_string(),
3590 input_path.display().to_string(),
3591 "-o".to_string(),
3592 output_path.display().to_string(),
3593 ],
3594 &mut stdin,
3595 &mut stdout,
3596 &mut stderr,
3597 );
3598
3599 let output_bytes = fs::read(&output_path).expect("read output file");
3600 let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3601
3602 let _ = fs::remove_file(&input_path);
3603 let _ = fs::remove_file(&output_path);
3604
3605 assert_eq!(exit_code, 0);
3606 assert!(stdout.is_empty());
3607 assert!(stderr.is_empty());
3608 assert_eq!(artifact.media_type, MediaType::Jpeg);
3609 }
3610
3611 #[test]
3612 fn run_with_io_optimizes_a_png_file() {
3613 let input_path = temp_file_path("optimize-input");
3614 let output_path = temp_file_path("optimize-output").with_extension("png");
3615 fs::write(&input_path, png_bytes()).expect("write input file");
3616
3617 let mut stdin = Cursor::new(Vec::<u8>::new());
3618 let mut stdout = Vec::new();
3619 let mut stderr = Vec::new();
3620
3621 let exit_code = run_with_io(
3622 vec![
3623 "truss".to_string(),
3624 "optimize".to_string(),
3625 input_path.display().to_string(),
3626 "-o".to_string(),
3627 output_path.display().to_string(),
3628 "--mode".to_string(),
3629 "lossless".to_string(),
3630 ],
3631 &mut stdin,
3632 &mut stdout,
3633 &mut stderr,
3634 );
3635
3636 let output_bytes = fs::read(&output_path).expect("read output file");
3637 let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3638
3639 let _ = fs::remove_file(&input_path);
3640 let _ = fs::remove_file(&output_path);
3641
3642 assert_eq!(exit_code, 0);
3643 assert!(stdout.is_empty());
3644 assert!(stderr.is_empty());
3645 assert_eq!(artifact.media_type, MediaType::Png);
3646 }
3647
3648 #[test]
3649 fn run_with_io_converts_stdin_to_stdout() {
3650 let mut stdin = Cursor::new(png_bytes());
3651 let mut stdout = Vec::new();
3652 let mut stderr = Vec::new();
3653
3654 let exit_code = run_with_io(
3655 vec![
3656 "truss".to_string(),
3657 "convert".to_string(),
3658 "-".to_string(),
3659 "-o".to_string(),
3660 "-".to_string(),
3661 "--format".to_string(),
3662 "png".to_string(),
3663 "--width".to_string(),
3664 "8".to_string(),
3665 ],
3666 &mut stdin,
3667 &mut stdout,
3668 &mut stderr,
3669 );
3670
3671 assert_eq!(
3672 exit_code,
3673 0,
3674 "stderr was: {}",
3675 String::from_utf8_lossy(&stderr)
3676 );
3677 assert!(stderr.is_empty());
3678
3679 let artifact = sniff_artifact(RawArtifact::new(stdout, None)).expect("sniff stdout output");
3680
3681 assert_eq!(artifact.media_type, MediaType::Png);
3682 assert_eq!(artifact.metadata.width, Some(8));
3683 }
3684
3685 #[test]
3686 fn run_with_io_converts_a_url_to_a_file() {
3687 let (url, handle) = spawn_http_server(png_bytes(), "image/png");
3688 let output_path = temp_file_path("convert-url-output").with_extension("png");
3689 let mut stdin = Cursor::new(Vec::<u8>::new());
3690 let mut stdout = Vec::new();
3691 let mut stderr = Vec::new();
3692
3693 let exit_code = run_with_io(
3694 vec![
3695 "truss".to_string(),
3696 "convert".to_string(),
3697 "--url".to_string(),
3698 url,
3699 "-o".to_string(),
3700 output_path.display().to_string(),
3701 "--width".to_string(),
3702 "8".to_string(),
3703 ],
3704 &mut stdin,
3705 &mut stdout,
3706 &mut stderr,
3707 );
3708
3709 handle.join().expect("join server thread");
3710
3711 let output_bytes = fs::read(&output_path).expect("read output file");
3712 let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3713 let _ = fs::remove_file(&output_path);
3714
3715 assert_eq!(exit_code, 0);
3716 assert!(stdout.is_empty());
3717 assert!(stderr.is_empty());
3718 assert_eq!(artifact.media_type, MediaType::Png);
3719 assert_eq!(artifact.metadata.width, Some(8));
3720 }
3721
3722 #[rstest]
3730 #[case("300 Multiple Choices")]
3731 #[case("305 Use Proxy")]
3732 #[case("306 Switch Proxy")]
3733 #[case("309 Unassigned")]
3734 #[case("400 Bad Request")]
3735 #[case("500 Internal Server Error")]
3736 fn a_status_that_is_not_success_is_the_origin_failing(#[case] status: &'static str) {
3737 let (url, handle) = spawn_http_server_with_status(status);
3738 let output_path = temp_file_path("convert-url-status").with_extension("png");
3739 let mut stdin = Cursor::new(Vec::<u8>::new());
3740 let mut stdout = Vec::new();
3741 let mut stderr = Vec::new();
3742
3743 let exit_code = run_with_io(
3744 vec![
3745 "truss".to_string(),
3746 "convert".to_string(),
3747 "--url".to_string(),
3748 url,
3749 "-o".to_string(),
3750 output_path.display().to_string(),
3751 ],
3752 &mut stdin,
3753 &mut stdout,
3754 &mut stderr,
3755 );
3756
3757 handle.join().expect("join server thread");
3758 let _ = fs::remove_file(&output_path);
3759 let message = String::from_utf8(stderr).expect("utf8 stderr");
3760
3761 assert_eq!(exit_code, EXIT_IO, "{status} is the origin's failure");
3762 assert!(
3763 message.contains("bad-gateway"),
3764 "{status} must be classified as the origin's failure, got: {message}"
3765 );
3766 let code = status.split(' ').next().expect("status code");
3767 assert!(
3768 message.contains(code),
3769 "{status} must be named in the message, got: {message}"
3770 );
3771 }
3772
3773 #[rstest]
3776 #[case("200 OK")]
3777 #[case("201 Created")]
3778 #[case("299 Also Fine")]
3779 fn a_success_status_is_the_resource(#[case] status: &'static str) {
3780 let (url, handle) = spawn_http_server_with_status(status);
3781 let output_path = temp_file_path("convert-url-success").with_extension("png");
3782 let mut stdin = Cursor::new(Vec::<u8>::new());
3783 let mut stdout = Vec::new();
3784 let mut stderr = Vec::new();
3785
3786 let exit_code = run_with_io(
3787 vec![
3788 "truss".to_string(),
3789 "convert".to_string(),
3790 "--url".to_string(),
3791 url,
3792 "-o".to_string(),
3793 output_path.display().to_string(),
3794 ],
3795 &mut stdin,
3796 &mut stdout,
3797 &mut stderr,
3798 );
3799
3800 handle.join().expect("join server thread");
3801 let _ = fs::remove_file(&output_path);
3802
3803 assert_eq!(
3804 exit_code,
3805 0,
3806 "{status} is a representation of the resource: {}",
3807 String::from_utf8_lossy(&stderr)
3808 );
3809 }
3810
3811 #[test]
3814 fn the_remote_caps_are_the_ones_the_server_publishes() {
3815 assert_eq!(
3816 MAX_REMOTE_BYTES,
3817 crate::adapters::server::remote::MAX_SOURCE_BYTES,
3818 "a source the server fetches must be one the command line can be pointed at"
3819 );
3820 assert_eq!(
3821 MAX_REMOTE_WATERMARK_BYTES,
3822 crate::adapters::server::remote::MAX_WATERMARK_BYTES,
3823 "a watermark the server refuses is one there is no point in accepting here"
3824 );
3825 }
3826
3827 #[test]
3828 fn run_with_io_reports_input_errors() {
3829 let mut stdin = Cursor::new(Vec::<u8>::new());
3830 let mut stdout = Vec::new();
3831 let mut stderr = Vec::new();
3832
3833 let exit_code = run_with_io(
3834 vec![
3835 "truss".to_string(),
3836 "inspect".to_string(),
3837 "missing-file.png".to_string(),
3838 ],
3839 &mut stdin,
3840 &mut stdout,
3841 &mut stderr,
3842 );
3843
3844 assert_eq!(exit_code, 2);
3845 assert!(stdout.is_empty());
3846 assert!(
3847 String::from_utf8(stderr)
3848 .expect("utf8 stderr")
3849 .contains("failed to read missing-file.png")
3850 );
3851 }
3852
3853 #[test]
3854 fn run_with_io_reports_decode_errors() {
3855 let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
3856 let mut stdout = Vec::new();
3857 let mut stderr = Vec::new();
3858
3859 let exit_code = run_with_io(
3860 vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
3861 &mut stdin,
3862 &mut stdout,
3863 &mut stderr,
3864 );
3865
3866 assert_eq!(exit_code, 3);
3867 assert!(stdout.is_empty());
3868 assert!(
3869 String::from_utf8(stderr)
3870 .expect("utf8 stderr")
3871 .contains("unknown file signature")
3872 );
3873 }
3874
3875 #[test]
3878 fn double_dash_input_with_options_before() {
3879 let result = parse_args(vec![
3881 "truss".to_string(),
3882 "convert".to_string(),
3883 "-o".to_string(),
3884 "out.jpg".to_string(),
3885 "--width".to_string(),
3886 "100".to_string(),
3887 "--".to_string(),
3888 "--leading-dash.png".to_string(),
3889 ]);
3890
3891 assert_eq!(
3892 result.unwrap(),
3893 Command::Convert(ConvertCommand {
3894 input: InputSource::Path(PathBuf::from("--leading-dash.png")),
3895 output: OutputTarget::Path(PathBuf::from("out.jpg")),
3896 options: TransformOptions {
3897 width: Some(100),
3898 ..TransformOptions::default()
3899 },
3900 watermark_path: None,
3901 watermark_position: None,
3902 watermark_opacity: None,
3903 watermark_margin: None,
3904 })
3905 );
3906 }
3907
3908 #[test]
3911 fn inspect_double_dash_allows_leading_dash() {
3912 let result = parse_args(vec![
3913 "truss".to_string(),
3914 "inspect".to_string(),
3915 "--".to_string(),
3916 "-weird-name.png".to_string(),
3917 ]);
3918
3919 assert_eq!(
3920 result.unwrap(),
3921 Command::Inspect(super::InspectCommand {
3922 input: InputSource::Path(PathBuf::from("-weird-name.png"))
3923 })
3924 );
3925 }
3926
3927 #[test]
3930 fn completions_bash_produces_output() {
3931 let mut stdin = Cursor::new(Vec::<u8>::new());
3932 let mut stdout = Vec::new();
3933 let mut stderr = Vec::new();
3934
3935 let exit_code = run_with_io(
3936 vec![
3937 "truss".to_string(),
3938 "completions".to_string(),
3939 "bash".to_string(),
3940 ],
3941 &mut stdin,
3942 &mut stdout,
3943 &mut stderr,
3944 );
3945
3946 assert_eq!(exit_code, 0);
3947 let output = String::from_utf8(stdout).expect("utf8 stdout");
3948 assert!(
3950 output.contains("truss"),
3951 "bash completions should mention truss"
3952 );
3953 }
3954
3955 #[test]
3956 fn completions_zsh_produces_output() {
3957 let mut stdin = Cursor::new(Vec::<u8>::new());
3958 let mut stdout = Vec::new();
3959 let mut stderr = Vec::new();
3960
3961 let exit_code = run_with_io(
3962 vec![
3963 "truss".to_string(),
3964 "completions".to_string(),
3965 "zsh".to_string(),
3966 ],
3967 &mut stdin,
3968 &mut stdout,
3969 &mut stderr,
3970 );
3971
3972 assert_eq!(exit_code, 0);
3973 assert!(!stdout.is_empty());
3974 }
3975
3976 #[test]
3977 fn completions_fish_produces_output() {
3978 let mut stdin = Cursor::new(Vec::<u8>::new());
3979 let mut stdout = Vec::new();
3980 let mut stderr = Vec::new();
3981
3982 let exit_code = run_with_io(
3983 vec![
3984 "truss".to_string(),
3985 "completions".to_string(),
3986 "fish".to_string(),
3987 ],
3988 &mut stdin,
3989 &mut stdout,
3990 &mut stderr,
3991 );
3992
3993 assert_eq!(exit_code, 0);
3994 assert!(!stdout.is_empty());
3995 }
3996
3997 #[test]
4000 fn dash_dash_version_prints_version() {
4001 let mut stdin = Cursor::new(Vec::<u8>::new());
4002 let mut stdout = Vec::new();
4003 let mut stderr = Vec::new();
4004
4005 let exit_code = run_with_io(
4006 vec!["truss".to_string(), "--version".to_string()],
4007 &mut stdin,
4008 &mut stdout,
4009 &mut stderr,
4010 );
4011
4012 assert_eq!(exit_code, 0);
4013 let output = String::from_utf8(stdout).expect("utf8 stdout");
4014 assert!(
4015 output.starts_with("truss "),
4016 "version output should start with 'truss ': {output}"
4017 );
4018 assert!(
4019 output.contains(env!("CARGO_PKG_VERSION")),
4020 "should contain package version: {output}"
4021 );
4022 }
4023
4024 #[test]
4025 fn dash_v_prints_version() {
4026 let mut stdin = Cursor::new(Vec::<u8>::new());
4027 let mut stdout = Vec::new();
4028 let mut stderr = Vec::new();
4029
4030 let exit_code = run_with_io(
4031 vec!["truss".to_string(), "-V".to_string()],
4032 &mut stdin,
4033 &mut stdout,
4034 &mut stderr,
4035 );
4036
4037 assert_eq!(exit_code, 0);
4038 let output = String::from_utf8(stdout).expect("utf8 stdout");
4039 assert!(output.contains(env!("CARGO_PKG_VERSION")));
4040 }
4041
4042 #[test]
4043 fn help_includes_version_and_sponsor() {
4044 let mut stdin = Cursor::new(Vec::<u8>::new());
4045 let mut stdout = Vec::new();
4046 let mut stderr = Vec::new();
4047
4048 let exit_code = run_with_io(
4049 vec!["truss".to_string(), "--help".to_string()],
4050 &mut stdin,
4051 &mut stdout,
4052 &mut stderr,
4053 );
4054
4055 assert_eq!(exit_code, 0);
4056 let output = String::from_utf8(stdout).expect("utf8 stdout");
4057 assert!(
4058 output.contains(env!("CARGO_PKG_VERSION")),
4059 "help should include version: {output}"
4060 );
4061 assert!(
4062 output.contains("Sponsor:"),
4063 "help should include sponsor link: {output}"
4064 );
4065 assert!(
4066 output.contains("github.com/sponsors/nao1215"),
4067 "help should include GitHub Sponsors URL: {output}"
4068 );
4069 }
4070
4071 #[test]
4074 fn preprocess_args_extensionless_file_is_implicit_convert() {
4075 let dir = temp_dir("extensionless");
4077 let file_path = dir.join("image");
4078 fs::write(&file_path, png_bytes()).expect("write extensionless fixture");
4079
4080 let original_dir = std::env::current_dir().expect("get cwd");
4083 std::env::set_current_dir(&dir).expect("set cwd to temp dir");
4084
4085 let args = vec![
4086 OsString::from("truss"),
4087 OsString::from("image"),
4088 OsString::from("-o"),
4089 OsString::from("out.jpg"),
4090 ];
4091 let result = preprocess_args(args);
4092
4093 std::env::set_current_dir(&original_dir).expect("restore cwd");
4094
4095 assert_eq!(
4096 result[1], "convert",
4097 "extensionless file should trigger implicit convert"
4098 );
4099 assert_eq!(result[2], "image", "bare file name should follow convert");
4100
4101 fs::remove_dir_all(&dir).ok();
4102 }
4103
4104 #[test]
4105 fn preprocess_args_nonexistent_extensionless_is_unknown_subcommand() {
4106 let args = vec![
4108 OsString::from("truss"),
4109 OsString::from("nonexistent_subcommand_xyz"),
4110 ];
4111 let result = preprocess_args(args.clone());
4112 assert_eq!(
4113 result, args,
4114 "non-existent extensionless name should pass through unchanged"
4115 );
4116 }
4117
4118 #[test]
4121 fn exit_code_invalid_options_is_usage_error() {
4122 let png_bytes = {
4124 let mut img = image::RgbaImage::new(1, 1);
4125 img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
4126 let mut buf = Vec::new();
4127 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
4128 image::ImageEncoder::write_image(
4129 encoder,
4130 img.as_raw(),
4131 1,
4132 1,
4133 image::ColorType::Rgba8.into(),
4134 )
4135 .unwrap();
4136 buf
4137 };
4138 let mut stdin = Cursor::new(png_bytes);
4139 let mut stdout = Vec::new();
4140 let mut stderr = Vec::new();
4141
4142 let code = run_with_io(
4143 vec![
4144 "truss".to_string(),
4145 "convert".to_string(),
4146 "-".to_string(),
4147 "-o".to_string(),
4148 "-".to_string(),
4149 "--format".to_string(),
4150 "jpeg".to_string(),
4151 "--quality".to_string(),
4152 "0".to_string(),
4153 ],
4154 &mut stdin,
4155 &mut stdout,
4156 &mut stderr,
4157 );
4158 assert_eq!(code, 1, "InvalidOptions should exit with code 1 (usage)");
4159 }
4160
4161 #[test]
4164 fn help_completions_shows_completions_help() {
4165 let result = parse_args(vec![
4166 "truss".to_string(),
4167 "help".to_string(),
4168 "completions".to_string(),
4169 ]);
4170 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
4171 }
4172
4173 #[test]
4174 fn help_version_shows_version_help() {
4175 let result = parse_args(vec![
4176 "truss".to_string(),
4177 "help".to_string(),
4178 "version".to_string(),
4179 ]);
4180 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Version));
4181 }
4182
4183 #[test]
4184 fn completions_dash_help_shows_completions_help() {
4185 let result = parse_args(vec![
4186 "truss".to_string(),
4187 "completions".to_string(),
4188 "--help".to_string(),
4189 ]);
4190 assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
4191 }
4192
4193 #[test]
4194 fn completions_without_shell_exits_with_usage_error() {
4195 let mut stdin = Cursor::new(Vec::<u8>::new());
4196 let mut stdout = Vec::new();
4197 let mut stderr = Vec::new();
4198
4199 let code = run_with_io(
4200 vec!["truss".to_string(), "completions".to_string()],
4201 &mut stdin,
4202 &mut stdout,
4203 &mut stderr,
4204 );
4205 assert_eq!(code, 1, "completions without shell arg should exit 1");
4206 }
4207
4208 #[test]
4211 fn completions_bash_includes_implicit_args() {
4212 let mut stdin = Cursor::new(Vec::<u8>::new());
4213 let mut stdout = Vec::new();
4214 let mut stderr = Vec::new();
4215
4216 let code = run_with_io(
4217 vec![
4218 "truss".to_string(),
4219 "completions".to_string(),
4220 "bash".to_string(),
4221 ],
4222 &mut stdin,
4223 &mut stdout,
4224 &mut stderr,
4225 );
4226 assert_eq!(code, 0);
4227 let output = String::from_utf8(stdout).expect("utf8 stdout");
4228 assert!(
4229 output.contains("--output"),
4230 "bash completions should include --output for implicit convert"
4231 );
4232 assert!(
4233 output.contains("--bind"),
4234 "bash completions should include --bind for implicit serve"
4235 );
4236 }
4237
4238 #[test]
4241 fn help_exit_codes_includes_runtime() {
4242 let text = super::help_top_level();
4243 assert!(
4244 text.contains("5 Runtime error"),
4245 "help text should document exit code 5"
4246 );
4247 }
4248
4249 #[test]
4252 fn unknown_help_topic_hint_lists_all_topics() {
4253 let result = parse_args(vec![
4254 "truss".to_string(),
4255 "help".to_string(),
4256 "nonexistent".to_string(),
4257 ]);
4258 let err = result.unwrap_err();
4259 let hint = err.hint.unwrap();
4260 assert!(hint.contains("completions"), "hint should list completions");
4261 assert!(hint.contains("version"), "hint should list version");
4262 }
4263 struct FlushFails;
4268
4269 impl Write for FlushFails {
4270 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
4271 Ok(buf.len())
4272 }
4273
4274 fn flush(&mut self) -> io::Result<()> {
4275 Err(io::Error::new(io::ErrorKind::StorageFull, "no space left"))
4276 }
4277 }
4278
4279 #[test]
4280 fn flush_stdout_turns_a_failed_flush_into_a_runtime_error() {
4281 let mut stderr = Vec::new();
4282 let code = flush_stdout(0, &mut FlushFails, &mut stderr);
4283
4284 assert_eq!(code, 5, "a lost payload must not exit 0");
4285 let text = String::from_utf8(stderr).expect("utf8 stderr");
4286 assert!(
4287 text.contains("error: failed to write stdout"),
4288 "stderr should name the failure, got: {text}"
4289 );
4290 }
4291
4292 #[test]
4293 fn flush_stdout_keeps_the_original_exit_code_when_the_command_already_failed() {
4294 let mut stderr = Vec::new();
4295 let code = flush_stdout(2, &mut FlushFails, &mut stderr);
4296
4297 assert_eq!(code, 2, "the first failure is the one worth reporting");
4298 assert!(stderr.is_empty(), "the flush error should not add noise");
4299 }
4300
4301 #[test]
4302 fn flush_stdout_passes_the_exit_code_through_when_the_flush_succeeds() {
4303 let mut stdout = Vec::new();
4304 let mut stderr = Vec::new();
4305
4306 assert_eq!(flush_stdout(0, &mut stdout, &mut stderr), 0);
4307 assert_eq!(flush_stdout(3, &mut stdout, &mut stderr), 3);
4308 assert!(stderr.is_empty());
4309 }
4310
4311 struct WriteFails;
4315
4316 impl Write for WriteFails {
4317 fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
4318 Err(io::Error::new(io::ErrorKind::StorageFull, "no space left"))
4319 }
4320
4321 fn flush(&mut self) -> io::Result<()> {
4322 Ok(())
4323 }
4324 }
4325
4326 #[test]
4327 fn help_and_version_report_a_failed_write_on_stderr() {
4328 for args in [vec!["truss", "--version"], vec!["truss", "help", "convert"]] {
4329 let mut stdin = Cursor::new(Vec::new());
4330 let mut stderr = Vec::new();
4331 let code = run_with_io(
4332 args.iter().map(|value| (*value).to_string()),
4333 &mut stdin,
4334 &mut WriteFails,
4335 &mut stderr,
4336 );
4337
4338 assert_eq!(
4339 code, 5,
4340 "{args:?} should exit 5 when stdout cannot be written"
4341 );
4342 let text = String::from_utf8(stderr).expect("utf8 stderr");
4343 assert!(
4344 text.contains("error: failed to write stdout"),
4345 "{args:?} should explain itself, got: {text}"
4346 );
4347 }
4348 }
4349
4350 #[test]
4353 fn a_gif_output_extension_is_rejected_like_the_gif_flag() {
4354 for args in [
4355 vec!["truss", "convert", "in.png", "-o", "out.gif"],
4356 vec!["truss", "convert", "in.png", "-o", "out.GIF"],
4357 vec!["truss", "optimize", "in.png", "-o", "out.gif"],
4358 vec![
4359 "truss", "convert", "in.png", "-o", "out.png", "--format", "gif",
4360 ],
4361 ] {
4362 let error = parse_args(args.iter().map(|value| (*value).to_string()))
4363 .expect_err("gif output should not parse");
4364
4365 assert_eq!(error.exit_code, 1, "{args:?} should be a usage error");
4366 assert!(
4367 error.message.contains("input-only format")
4368 && error.message.contains("png, jpeg, webp, or avif"),
4369 "{args:?} should name the alternatives, got: {}",
4370 error.message
4371 );
4372 }
4373 }
4374
4375 #[test]
4376 fn an_explicit_format_overrides_an_unencodable_output_extension() {
4377 let command = parse_args(
4378 [
4379 "truss", "convert", "in.png", "-o", "out.gif", "--format", "png",
4380 ]
4381 .iter()
4382 .map(|value| (*value).to_string()),
4383 )
4384 .expect("an explicit format decides the encoder, whatever the extension says");
4385
4386 match command {
4387 Command::Convert(convert) => assert_eq!(convert.options.format, Some(MediaType::Png)),
4388 other => panic!("expected a convert command, got {other:?}"),
4389 }
4390 }
4391
4392 #[cfg(target_os = "linux")]
4403 #[test]
4404 fn a_non_utf8_input_path_is_converted_rather_than_refused() {
4405 use std::os::unix::ffi::OsStringExt;
4406
4407 let dir = temp_dir("non-utf8-input");
4408 let mut name = OsString::from_vec(b"caf\xe9".to_vec());
4409 name.push(".png");
4410 let input_path = dir.join(name);
4411 fs::write(&input_path, png_bytes()).expect("write input file");
4412 let output_path = dir.join("out.png");
4413
4414 let mut stdin = Cursor::new(Vec::<u8>::new());
4415 let mut stdout = Vec::new();
4416 let mut stderr = Vec::new();
4417
4418 let exit_code = run_with_io(
4419 vec![
4420 OsString::from("truss"),
4421 OsString::from("convert"),
4422 input_path.clone().into_os_string(),
4423 OsString::from("-o"),
4424 output_path.clone().into_os_string(),
4425 OsString::from("--width"),
4426 OsString::from("2"),
4427 ],
4428 &mut stdin,
4429 &mut stdout,
4430 &mut stderr,
4431 );
4432
4433 let output_exists = output_path.is_file();
4434 let _ = fs::remove_dir_all(&dir);
4435
4436 assert_eq!(
4437 exit_code,
4438 0,
4439 "stderr was: {}",
4440 String::from_utf8_lossy(&stderr)
4441 );
4442 assert!(
4443 output_exists,
4444 "the conversion should have written an output"
4445 );
4446 }
4447
4448 #[cfg(target_os = "linux")]
4452 #[test]
4453 fn a_non_utf8_output_path_is_written_under_exactly_those_bytes() {
4454 use std::os::unix::ffi::OsStringExt;
4455
4456 let dir = temp_dir("non-utf8-output");
4457 let input_path = dir.join("in.png");
4458 fs::write(&input_path, png_bytes()).expect("write input file");
4459 let mut name = OsString::from_vec(b"sortie-\xe9".to_vec());
4460 name.push(".png");
4461 let output_path = dir.join(name);
4462
4463 let mut stdin = Cursor::new(Vec::<u8>::new());
4464 let mut stdout = Vec::new();
4465 let mut stderr = Vec::new();
4466
4467 let exit_code = run_with_io(
4468 vec![
4469 OsString::from("truss"),
4470 OsString::from("convert"),
4471 input_path.into_os_string(),
4472 OsString::from("-o"),
4473 output_path.clone().into_os_string(),
4474 ],
4475 &mut stdin,
4476 &mut stdout,
4477 &mut stderr,
4478 );
4479
4480 let output_exists = output_path.is_file();
4481 let _ = fs::remove_dir_all(&dir);
4482
4483 assert_eq!(
4484 exit_code,
4485 0,
4486 "stderr was: {}",
4487 String::from_utf8_lossy(&stderr)
4488 );
4489 assert!(
4490 output_exists,
4491 "the output should exist under the bytes that were asked for"
4492 );
4493 }
4494
4495 #[cfg(unix)]
4498 #[test]
4499 fn a_non_utf8_value_for_a_text_flag_is_a_usage_error() {
4500 use std::os::unix::ffi::OsStringExt;
4501
4502 let mut stdin = Cursor::new(png_bytes());
4503 let mut stdout = Vec::new();
4504 let mut stderr = Vec::new();
4505
4506 let exit_code = run_with_io(
4507 vec![
4508 OsString::from("truss"),
4509 OsString::from("convert"),
4510 OsString::from("-"),
4511 OsString::from("-o"),
4512 OsString::from("-"),
4513 OsString::from("--format"),
4514 OsString::from_vec(vec![0xff]),
4515 ],
4516 &mut stdin,
4517 &mut stdout,
4518 &mut stderr,
4519 );
4520
4521 assert_eq!(exit_code, EXIT_USAGE, "a value that is not text is usage");
4522 assert!(stdout.is_empty(), "nothing should be written on a refusal");
4523 }
4524
4525 #[test]
4530 fn a_missing_watermark_is_not_found_like_a_missing_input() {
4531 let dir = temp_dir("watermark-class");
4532 let input_path = dir.join("in.png");
4533 fs::write(&input_path, png_bytes()).expect("write input file");
4534 let output_path = dir.join("out.png");
4535
4536 let mut stdin = Cursor::new(Vec::<u8>::new());
4537 let mut stdout = Vec::new();
4538 let mut stderr = Vec::new();
4539
4540 let exit_code = run_with_io(
4541 vec![
4542 "truss".to_string(),
4543 "convert".to_string(),
4544 input_path.display().to_string(),
4545 "-o".to_string(),
4546 output_path.display().to_string(),
4547 "--watermark".to_string(),
4548 dir.join("absent.png").display().to_string(),
4549 ],
4550 &mut stdin,
4551 &mut stdout,
4552 &mut stderr,
4553 );
4554
4555 let message = String::from_utf8(stderr).expect("utf8 stderr");
4556 let _ = fs::remove_dir_all(&dir);
4557
4558 assert_eq!(exit_code, 2, "a file system fault is exit 2");
4559 assert!(
4560 message.contains("(not-found)"),
4561 "a missing watermark should be not-found, got: {message}"
4562 );
4563 }
4564
4565 #[cfg(unix)]
4568 #[test]
4569 fn an_unreadable_watermark_is_an_internal_error() {
4570 use std::os::unix::fs::PermissionsExt;
4571
4572 let dir = temp_dir("watermark-unreadable");
4573 let input_path = dir.join("in.png");
4574 fs::write(&input_path, png_bytes()).expect("write input file");
4575 let watermark_path = dir.join("wm.png");
4576 fs::write(&watermark_path, png_bytes()).expect("write watermark file");
4577 fs::set_permissions(&watermark_path, fs::Permissions::from_mode(0o000))
4578 .expect("make the watermark unreadable");
4579 let output_path = dir.join("out.png");
4580
4581 let mut stdin = Cursor::new(Vec::<u8>::new());
4582 let mut stdout = Vec::new();
4583 let mut stderr = Vec::new();
4584
4585 let exit_code = run_with_io(
4586 vec![
4587 "truss".to_string(),
4588 "convert".to_string(),
4589 input_path.display().to_string(),
4590 "-o".to_string(),
4591 output_path.display().to_string(),
4592 "--watermark".to_string(),
4593 watermark_path.display().to_string(),
4594 ],
4595 &mut stdin,
4596 &mut stdout,
4597 &mut stderr,
4598 );
4599
4600 let message = String::from_utf8(stderr).expect("utf8 stderr");
4601 let _ = fs::set_permissions(&watermark_path, fs::Permissions::from_mode(0o644));
4602 let _ = fs::remove_dir_all(&dir);
4603
4604 if message.contains("(not-found)") {
4605 return;
4607 }
4608 assert_eq!(exit_code, 2, "a file system fault is exit 2");
4609 assert!(
4610 message.contains("(internal-error)"),
4611 "a watermark that cannot be read is not a missing one, got: {message}"
4612 );
4613 }
4614
4615 #[test]
4623 fn a_truncated_jpeg_is_reported_on_one_line() {
4624 const TRUNCATED_JPEG: &[u8] = include_bytes!("../../../integration/fixtures/truncated.jpg");
4625
4626 let mut stdin = Cursor::new(TRUNCATED_JPEG.to_vec());
4627 let mut stdout = Vec::new();
4628 let mut stderr = Vec::new();
4629
4630 let exit_code = run_with_io(
4631 vec![
4632 "truss".to_string(),
4633 "convert".to_string(),
4634 "-".to_string(),
4635 "-o".to_string(),
4636 "-".to_string(),
4637 "--format".to_string(),
4638 "png".to_string(),
4639 ],
4640 &mut stdin,
4641 &mut stdout,
4642 &mut stderr,
4643 );
4644
4645 let rendered = String::from_utf8(stderr).expect("utf8 stderr");
4646
4647 assert_eq!(exit_code, EXIT_TRANSFORM);
4648 assert_eq!(
4649 rendered.lines().count(),
4650 1,
4651 "one failure is one line, got: {rendered:?}"
4652 );
4653 assert!(
4654 rendered.trim_end().ends_with("(decode-failed)"),
4655 "the class ends the line, got: {rendered:?}"
4656 );
4657 }
4658
4659 #[test]
4662 fn write_error_keeps_a_message_with_a_newline_on_one_line() {
4663 let mut stderr = Vec::new();
4664 let code = super::write_error(
4665 &mut stderr,
4666 super::classified_error(
4667 crate::core::error_class::ErrorClass::DecodeFailed,
4668 EXIT_TRANSFORM,
4669 "decoding failed\n",
4670 ),
4671 );
4672
4673 let rendered = String::from_utf8(stderr).expect("utf8 stderr");
4674
4675 assert_eq!(code, EXIT_TRANSFORM);
4676 assert_eq!(
4677 rendered.lines().count(),
4678 1,
4679 "one failure is one line, got: {rendered:?}"
4680 );
4681 assert!(
4682 rendered.ends_with("(decode-failed)\n"),
4683 "the class ends the line, got: {rendered:?}"
4684 );
4685 }
4686
4687 #[test]
4688 fn svg_output_from_a_raster_input_names_the_rule_it_broke() {
4689 let error = crate::TransformError::UnsupportedOutputMediaType(MediaType::Svg).to_string();
4690
4691 assert!(
4692 error.contains("requires an svg input"),
4693 "the message should say why svg was refused, got: {error}"
4694 );
4695 }
4696}