diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a20831..136e6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ double-quoted HTML attributes or CSS strings; this keeps text formats like SVG short and readable - `DataUrl::write_to()`, which writes a data URL to any `std::io::Write` without building it as a string first - `DataUrl::encoded_len()`, the exact length of the serialized data URL, e.g. for preallocating buffers + - `extension_for_media_type()`, the counterpart of `detect_media_type()`, which suggests a file extension + for a media type (e.g. `svg` for `image/svg+xml`) + - CLI options: + - `-s`/`--shortest` and `-m`/`--minimal`, for `DataEncoding::Shortest` and `Escaping::Minimal` + - `-i` can be given several times, which outputs one data URL per line + - `--size` prints the lengths of the percent-encoded and base64-encoded data URLs instead of the data URL + - `-O`/`--output-dir` decodes into files named after the input files, with extensions matching their media types ### Changed diff --git a/README.md b/README.md index 632f2d3..4fedd2a 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,31 @@ cat file.txt | dataurl -i - -o - | dataurl -d cat file.png | dataurl -b ``` +```console +dataurl -s -m -i logo.svg -i photo.jpg -i font.woff2 > urls.txt +``` + +```console +dataurl --size -i logo.svg -i photo.jpg +``` + +```console +dataurl -d -O assets/ -i logo.txt -i photo.txt +``` + ### Flags and options - `-b`: Encode data using base64 + - `-s`: Use base64 or percent-encoding, whichever results in a shorter data URL + - `-m`: Only escape what has to be when percent-encoding (for data URLs that go inside double-quoted HTML attributes or CSS strings) - `-c`: Use custom `charset` (text given as an argument gets converted to it) - - `-d`: Attempt to parse input, output resulting data - `-f`: Append `fragment` - - `-i`: Specify `file` to read data from (use `-` for STDIN) - - `-o`: Provide `file` to write output to (use `-` for STDOUT) - `-t`: Adjust `media type` (detected automatically for files and STDIN) + - `-i`: Specify `file` to read data from (use `-` for STDIN); can be given several times, for one data URL per line + - `-o`: Provide `file` to write output to (use `-` for STDOUT) + - `--size`: Print the lengths of the percent-encoded and base64-encoded data URLs instead, tab-separated, followed by the file name (if any) + - `-d`: Attempt to parse input, output resulting data + - `-O`: Decode into files in `directory`, named after the input files, with extensions matching their media types (the names written get printed) --------------------------------------------------- @@ -136,6 +152,15 @@ assert_eq!(detect_media_type(b"\x89PNG\r\n\x1a\n", None), Some("image/png")); assert_eq!(detect_media_type(b"body{}", Some("style.css")), Some("text/css")); ``` +`extension_for_media_type()` goes the other way, e.g. for saving decoded data to files: + +```rust +use dataurl::extension_for_media_type; + +assert_eq!(extension_for_media_type("image/svg+xml"), Some("svg")); +assert_eq!(extension_for_media_type("application/javascript"), Some("js")); +``` + If all you need is parsing, [data-url](https://crates.io/crates/data-url) (from the Servo project) is another spec-compliant option; dataurl also generates data URLs (picking the shortest encoding if asked to), handles charsets, detects media types, and comes with a CLI tool. diff --git a/src/detect.rs b/src/detect.rs index c641ae6..7d2aad8 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -4,69 +4,125 @@ use infer::MatcherType; const SVG: &str = "image/svg+xml"; -/// Media types for file extensions (lowercase), using the names browsers expect. +/// Media types and their file extensions (lowercase, the preferred one first), +/// using the media type names browsers expect. /// This is also used to normalize what infer returns, since some of its names are legacy ones /// (e.g. application/font-woff for both WOFF and WOFF2). +const MEDIA_TYPES: &[(&str, &[&str])] = &[ + // Images + ("image/apng", &["apng"]), + ("image/avif", &["avif"]), + ("image/bmp", &["bmp"]), + ("image/gif", &["gif"]), + ("image/x-icon", &["ico", "cur"]), + ("image/jpeg", &["jpg", "jpeg", "jfif", "pjpeg", "pjp"]), + ("image/jxl", &["jxl"]), + ("image/png", &["png"]), + (SVG, &["svg", "svgz"]), + ("image/tiff", &["tif", "tiff"]), + ("image/webp", &["webp"]), + // Audio + ("audio/aac", &["aac"]), + ("audio/flac", &["flac"]), + ("audio/mp4", &["m4a"]), + ("audio/midi", &["mid", "midi"]), + ("audio/mpeg", &["mp3"]), + ("audio/ogg", &["oga", "ogg"]), + ("audio/opus", &["opus"]), + ("audio/wav", &["wav"]), + ("audio/webm", &["weba"]), + // Video + ("video/avi", &["avi"]), + ("video/mp4", &["mp4", "m4v"]), + ("video/x-matroska", &["mkv"]), + ("video/quicktime", &["mov"]), + ("video/mpeg", &["mpeg", "mpg"]), + ("video/ogg", &["ogv"]), + ("video/webm", &["webm"]), + // Fonts + ("application/vnd.ms-fontobject", &["eot"]), + ("font/otf", &["otf"]), + ("font/ttf", &["ttf"]), + ("font/woff", &["woff"]), + ("font/woff2", &["woff2"]), + // Text and documents + ("text/css", &["css"]), + ("text/csv", &["csv"]), + ("text/html", &["html", "htm"]), + ("text/calendar", &["ics"]), + ("text/javascript", &["js", "mjs", "cjs"]), + ("application/json", &["json", "map"]), + ("application/ld+json", &["jsonld"]), + ("text/markdown", &["md", "markdown"]), + ("application/pdf", &["pdf"]), + ("application/rss+xml", &["rss"]), + ("text/plain", &["txt"]), + ("text/vtt", &["vtt"]), + ("application/manifest+json", &["webmanifest"]), + ("application/xhtml+xml", &["xhtml", "xht"]), + ("text/xml", &["xml", "xsl"]), + // Other + ("application/x-shockwave-flash", &["swf"]), + ("application/wasm", &["wasm"]), + ("application/zip", &["zip"]), +]; + +/// Legacy and alternative names for media types in [`MEDIA_TYPES`] +const ALIASES: &[(&str, &str)] = &[ + ("application/javascript", "text/javascript"), + ("application/x-javascript", "text/javascript"), + ("text/ecmascript", "text/javascript"), + ("application/xml", "text/xml"), + ("image/jpg", "image/jpeg"), + ("image/pjpeg", "image/jpeg"), + ("image/vnd.microsoft.icon", "image/x-icon"), + ("audio/mp3", "audio/mpeg"), + ("audio/x-wav", "audio/wav"), + ("audio/wave", "audio/wav"), + ("audio/x-flac", "audio/flac"), + ("video/x-msvideo", "video/avi"), + ("application/font-woff", "font/woff"), + ("application/x-font-ttf", "font/ttf"), + ("application/x-font-otf", "font/otf"), +]; + fn media_type_for_extension(extension: &str) -> Option<&'static str> { - Some(match extension { - // Images - "apng" => "image/apng", - "avif" => "image/avif", - "bmp" => "image/bmp", - "gif" => "image/gif", - "ico" | "cur" => "image/x-icon", - "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" => "image/jpeg", - "jxl" => "image/jxl", - "png" => "image/png", - "svg" | "svgz" => SVG, - "tif" | "tiff" => "image/tiff", - "webp" => "image/webp", - // Audio - "aac" => "audio/aac", - "flac" => "audio/flac", - "m4a" => "audio/mp4", - "mid" | "midi" => "audio/midi", - "mp3" => "audio/mpeg", - "oga" | "ogg" => "audio/ogg", - "opus" => "audio/opus", - "wav" => "audio/wav", - "weba" => "audio/webm", - // Video - "avi" => "video/avi", - "m4v" | "mp4" => "video/mp4", - "mkv" => "video/x-matroska", - "mov" => "video/quicktime", - "mpeg" | "mpg" => "video/mpeg", - "ogv" => "video/ogg", - "webm" => "video/webm", - // Fonts - "eot" => "application/vnd.ms-fontobject", - "otf" => "font/otf", - "ttf" => "font/ttf", - "woff" => "font/woff", - "woff2" => "font/woff2", - // Text and documents - "css" => "text/css", - "csv" => "text/csv", - "htm" | "html" => "text/html", - "ics" => "text/calendar", - "js" | "mjs" | "cjs" => "text/javascript", - "json" | "map" => "application/json", - "jsonld" => "application/ld+json", - "md" | "markdown" => "text/markdown", - "pdf" => "application/pdf", - "rss" => "application/rss+xml", - "txt" => "text/plain", - "vtt" => "text/vtt", - "webmanifest" => "application/manifest+json", - "xhtml" | "xht" => "application/xhtml+xml", - "xml" | "xsl" => "text/xml", - // Other - "swf" => "application/x-shockwave-flash", - "wasm" => "application/wasm", - "zip" => "application/zip", - _ => return None, - }) + MEDIA_TYPES + .iter() + .find(|(_, extensions)| extensions.contains(&extension)) + .map(|&(media_type, _)| media_type) +} + +/// Suggests a file extension (without the dot) for a media type, e.g. `"png"` for `"image/png"`. +/// +/// Matching is case-insensitive, parameters are ignored, and common legacy names +/// (like `application/javascript` or `image/jpg`) are recognized too. +/// This is the counterpart of [`detect_media_type()`], meant for saving decoded data to files. +/// +/// ``` +/// use dataurl::extension_for_media_type; +/// +/// assert_eq!(extension_for_media_type("image/png"), Some("png")); +/// assert_eq!(extension_for_media_type("Image/SVG+XML"), Some("svg")); +/// assert_eq!(extension_for_media_type("text/html; charset=utf-8"), Some("html")); +/// assert_eq!(extension_for_media_type("application/javascript"), Some("js")); +/// assert_eq!(extension_for_media_type("x/unknown"), None); +/// ``` +pub fn extension_for_media_type(media_type: &str) -> Option<&'static str> { + let essence = media_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let essence = ALIASES + .iter() + .find(|(alias, _)| *alias == essence) + .map_or(essence.as_str(), |&(_, media_type)| media_type); + MEDIA_TYPES + .iter() + .find(|(media_type, _)| *media_type == essence) + .map(|(_, extensions)| extensions[0]) } fn file_extension(file_name: &str) -> Option { @@ -159,6 +215,19 @@ mod tests { assert_eq!(file_extension("trailing."), None); } + #[test] + fn every_media_type_maps_back_to_its_extensions() { + for &(media_type, extensions) in MEDIA_TYPES { + assert_eq!(extension_for_media_type(media_type), Some(extensions[0])); + for extension in extensions { + assert_eq!(media_type_for_extension(extension), Some(media_type)); + } + } + for &(alias, media_type) in ALIASES { + assert!(MEDIA_TYPES.iter().any(|&(m, _)| m == media_type), "{alias}"); + } + } + #[test] fn svg_tag_needs_a_delimiter() { assert!(starts_with_svg_tag(b"")); diff --git a/src/lib.rs b/src/lib.rs index ee46a6f..44d7018 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ mod detect; pub mod mime; mod percent; -pub use detect::detect_media_type; +pub use detect::{detect_media_type, extension_for_media_type}; pub use mime::MimeType; use encoding_rs::{Encoding, REPLACEMENT, UTF_16BE, UTF_16LE, WINDOWS_1252}; diff --git a/src/main.rs b/src/main.rs index 72c969b..4133334 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ -use dataurl::{detect_media_type, DataEncoding, DataUrl}; +use dataurl::{detect_media_type, extension_for_media_type, DataEncoding, DataUrl, Escaping}; +use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::io::{self, IsTerminal, Read, Write}; @@ -24,12 +25,20 @@ Arguments: Options: -b, --base64 Encode data using base64 + -s, --shortest Use base64 or percent-encoding, whichever is shorter + -m, --minimal Only escape what has to be when percent-encoding + (for double-quoted HTML attributes and CSS strings) -c, --charset Set charset (INPUT text gets converted to it) - -d, --decode Decode a data URL and output its data -f, --fragment Append URL fragment - -i, --input-file Read input from FILE (\"-\" for STDIN) - -o, --output-file Write output to FILE (\"-\" for STDOUT) -t, --media-type Set media type (detected for files and STDIN if omitted) + -i, --input-file Read input from FILE (\"-\" for STDIN); can be given + several times, for one data URL per line + -o, --output-file Write output to FILE (\"-\" for STDOUT) + --size Print the lengths of the percent-encoded and base64-encoded + data URLs instead (followed by the file name, if any) + -d, --decode Decode a data URL and output its data + -O, --output-dir Decode into files in DIR, named after the input files, + with extensions matching their media types -h, --help Print help -V, --version Print version ", @@ -54,11 +63,15 @@ fn fail_with_usage(message: &str) -> ! { #[derive(Default)] struct Options { base64: bool, + shortest: bool, + minimal: bool, + size: bool, charset: Option, decode: bool, fragment: Option, - input_file: Option, + input_files: Vec, output_file: Option, + output_dir: Option, media_type: Option, input: Option, } @@ -114,12 +127,16 @@ fn parse_args(args: impl Iterator) -> Options { }; match flag.as_str() { - "-b" | "--base64" | "-d" | "--decode" | "-h" | "--help" | "-V" | "--version" + "-b" | "--base64" | "-s" | "--shortest" | "-m" | "--minimal" | "--size" | "-d" + | "--decode" | "-h" | "--help" | "-V" | "--version" if inline_value.is_some() => { fail_with_usage(&format!("unexpected value for '{flag}'")) } "-b" | "--base64" => options.base64 = true, + "-s" | "--shortest" => options.shortest = true, + "-m" | "--minimal" => options.minimal = true, + "--size" => options.size = true, "-d" | "--decode" => options.decode = true, "-h" | "--help" => { print!("{}", help()); @@ -131,8 +148,9 @@ fn parse_args(args: impl Iterator) -> Options { } "-c" | "--charset" => options.charset = Some(into_string(value("--charset"))), "-f" | "--fragment" => options.fragment = Some(into_string(value("--fragment"))), - "-i" | "--input-file" => options.input_file = Some(value("--input-file")), + "-i" | "--input-file" => options.input_files.push(value("--input-file")), "-o" | "--output-file" => options.output_file = Some(value("--output-file")), + "-O" | "--output-dir" => options.output_dir = Some(value("--output-dir")), "-t" | "--media-type" => options.media_type = Some(into_string(value("--media-type"))), _ => fail_with_usage(&format!("unexpected argument '{arg_str}'")), } @@ -168,37 +186,232 @@ fn write_output( } } +/// Where a piece of input comes from +enum Source { + Argument(String), + File(OsString), + Stdin, +} + +impl Source { + fn read(&self) -> Result, String> { + match self { + Source::Argument(text) => Ok(text.as_bytes().to_vec()), + Source::File(path) => fs::read(path).map_err(|e| { + format!( + "Unable to read input file '{}': {e}", + path.to_string_lossy() + ) + }), + Source::Stdin => { + let mut buffer: Vec = Vec::new(); + io::stdin() + .lock() + .read_to_end(&mut buffer) + .map_err(|e| format!("Unable to read STDIN: {e}"))?; + Ok(buffer) + } + } + } + + /// The file name (without directories), used for detecting media types + fn file_name(&self) -> Option<&str> { + match self { + Source::File(path) => Path::new(path).file_name()?.to_str(), + _ => None, + } + } + + /// The file name without its extension, used for naming decoded files + fn file_stem(&self) -> String { + match self { + Source::File(path) => Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "data".to_string()), + _ => "data".to_string(), + } + } +} + +fn sources(options: &mut Options) -> Vec { + if let Some(text) = options.input.take() { + if !options.input_files.is_empty() { + fail("Both file and argument inputs provided"); + } + return vec![Source::Argument(text)]; + } + + if options.input_files.is_empty() { + return vec![Source::Stdin]; + } + + let sources: Vec = options + .input_files + .drain(..) + // "-" means STDIN, same as not specifying the option at all + .map(|path| match path == "-" { + true => Source::Stdin, + false => Source::File(path), + }) + .collect(); + + if sources + .iter() + .filter(|s| matches!(s, Source::Stdin)) + .count() + > 1 + { + fail_with_usage("STDIN can only be read once"); + } + + sources +} + +fn encode(options: &Options, source: &Source, input: Vec) -> DataUrl { + let mut data_url = DataUrl::new(); + + if options.base64 { + data_url.set_data_encoding(DataEncoding::Base64); + } else if options.shortest { + data_url.set_data_encoding(DataEncoding::Shortest); + } + + if options.minimal { + data_url.set_escaping(Escaping::Minimal); + } + + if let Some(media_type) = &options.media_type { + data_url + .set_media_type(Some(media_type)) + .unwrap_or_else(|_| fail(&format!("Invalid media type '{media_type}'"))); + } else if !matches!(source, Source::Argument(_)) { + // Files and STDIN get their media type detected (text given as an argument has none) + if let Some(media_type) = detect_media_type(&input, source.file_name()) { + data_url.set_media_type(Some(media_type)).unwrap(); + } + } + + match (&options.charset, source) { + (Some(charset), source) => { + data_url + .set_charset(Some(charset)) + .unwrap_or_else(|_| fail(&format!("Invalid encoding '{charset}'"))); + + match source { + // Text given as an argument is UTF-8, so it gets converted to the charset + Source::Argument(text) => data_url.set_text(text).unwrap_or_else(|_| { + fail(&format!( + "Input text can't be represented in {}", + data_url.charset() + )) + }), + // Files and STDIN are taken as-is + _ => data_url.set_data(input), + } + } + (None, Source::Argument(text)) => { + if !text.is_ascii() { + data_url.set_charset(Some("UTF-8")).unwrap(); + } + data_url.set_data(input); + } + (None, _) => data_url.set_data(input), + } + + if let Some(fragment) = &options.fragment { + data_url.set_fragment(Some(fragment)); + } + + data_url +} + +/// The extension for a decoded file: from the media type if it's a known one, +/// otherwise from the data's signature, otherwise "txt" or "bin" +fn extension_for(data_url: &DataUrl) -> &'static str { + extension_for_media_type(data_url.media_type()) + .or_else(|| detect_media_type(data_url.data(), None).and_then(extension_for_media_type)) + .unwrap_or(if data_url.is_binary() { "bin" } else { "txt" }) +} + +/// Picks a file name that hasn't been used yet in this run: "name.ext", "name-2.ext", … +fn unique_file_name(stem: &str, extension: &str, used: &mut HashSet) -> String { + // Files like "logo.png.txt" become "logo.png" rather than "logo.png.png" + let suffix = format!(".{extension}"); + let stem = match stem.len() > suffix.len() + && stem.to_ascii_lowercase().ends_with(&suffix) + && stem.is_char_boundary(stem.len() - suffix.len()) + { + true => &stem[..stem.len() - suffix.len()], + false => stem, + }; + + let mut name = format!("{stem}.{extension}"); + let mut number = 2; + while !used.insert(name.clone()) { + name = format!("{stem}-{number}.{extension}"); + number += 1; + } + name +} + +fn decode(source: &Source) -> DataUrl { + let input = source.read().unwrap_or_else(|message| fail(&message)); + DataUrl::parse(&String::from_utf8_lossy(&input)).unwrap_or_else(|e| match source { + Source::File(path) => fail(&format!("{}: {e}", path.to_string_lossy())), + _ => fail(&e.to_string()), + }) +} + fn main() { let mut options = parse_args(std::env::args_os().skip(1)); - // "-" means STDIN/STDOUT, same as not specifying the option at all - options.input_file = options.input_file.filter(|path| path != "-"); + // "-" means STDOUT, same as not specifying the option at all options.output_file = options.output_file.filter(|path| path != "-"); - if options.input.is_some() && options.input_file.is_some() { - fail("Both file and argument inputs provided"); + if options.base64 && options.shortest { + fail_with_usage("'--base64' and '--shortest' can't be used together"); + } + if options.decode && options.size { + fail_with_usage("'--size' can't be used together with '--decode'"); } + if options.output_dir.is_some() && !options.decode { + fail_with_usage("'--output-dir' only works together with '--decode'"); + } + if options.output_dir.is_some() && options.output_file.is_some() { + fail_with_usage("'--output-file' and '--output-dir' can't be used together"); + } + + let sources = sources(&mut options); - let input: Vec = if let Some(input) = &options.input { - input.as_bytes().to_vec() - } else if let Some(path) = &options.input_file { - fs::read(path).unwrap_or_else(|e| { + if let Some(output_dir) = &options.output_dir { + let output_dir = Path::new(output_dir); + fs::create_dir_all(output_dir).unwrap_or_else(|e| { fail(&format!( - "Unable to read input file '{}': {e}", - path.to_string_lossy() + "Unable to create directory '{}': {e}", + output_dir.display() )) - }) - } else { - let mut buffer: Vec = Vec::new(); - if let Err(e) = io::stdin().lock().read_to_end(&mut buffer) { - fail(&format!("Unable to read STDIN: {e}")); - } - buffer - }; + }); - if options.decode { - let data_url = DataUrl::parse(&String::from_utf8_lossy(&input)) - .unwrap_or_else(|e| fail(&e.to_string())); + let mut used_names = HashSet::new(); + for source in &sources { + let data_url = decode(source); + let name = unique_file_name( + &source.file_stem(), + extension_for(&data_url), + &mut used_names, + ); + let path = output_dir.join(name); + fs::write(&path, data_url.data()) + .unwrap_or_else(|e| fail(&format!("Unable to write to '{}': {e}", path.display()))); + // Report what got written where, which matters since the names are picked automatically + write_output(&None, |writer| writeln!(writer, "{}", path.display())); + } + } else if options.decode { + if sources.len() > 1 { + fail_with_usage("decoding more than one input needs '--output-dir'"); + } + let data_url = decode(&sources[0]); if options.output_file.is_none() && io::stdout().is_terminal() && !data_url.is_binary() { // Text printed into a terminal gets converted from the data URL's charset @@ -210,62 +423,31 @@ fn main() { }); } } else { - let mut data_url = DataUrl::new(); - - if options.base64 { - data_url.set_data_encoding(DataEncoding::Base64); - } - - if let Some(media_type) = &options.media_type { - data_url - .set_media_type(Some(media_type)) - .unwrap_or_else(|_| fail(&format!("Invalid media type '{media_type}'"))); - } else if options.input.is_none() { - // Files and STDIN get their media type detected (text given as an argument has none) - let file_name = options - .input_file - .as_deref() - .and_then(|path| Path::new(path).file_name()) - .and_then(|name| name.to_str()); - if let Some(media_type) = detect_media_type(&input, file_name) { - data_url.set_media_type(Some(media_type)).unwrap(); - } - } - - match (&options.charset, &options.input) { - (Some(charset), text) => { - data_url - .set_charset(Some(charset)) - .unwrap_or_else(|_| fail(&format!("Invalid encoding '{charset}'"))); - - match text { - // Text given as an argument is UTF-8, so it gets converted to the charset - Some(text) => data_url.set_text(text).unwrap_or_else(|_| { - fail(&format!( - "Input text can't be represented in {}", - data_url.charset() - )) - }), - // Files and STDIN are taken as-is - None => data_url.set_data(input), - } - } - (None, Some(text)) => { - data_url.set_data(text.as_bytes()); - if !text.is_ascii() { - data_url.set_charset(Some("UTF-8")).unwrap(); + write_output(&options.output_file, |writer| { + for source in &sources { + let input = source.read().unwrap_or_else(|message| { + // Keep what's been written so far + let _ = writer.flush(); + fail(&message) + }); + let mut data_url = encode(&options, source, input); + + if options.size { + data_url.set_data_encoding(DataEncoding::Percent); + let percent_encoded_length = data_url.encoded_len(); + data_url.set_data_encoding(DataEncoding::Base64); + let base64_encoded_length = data_url.encoded_len(); + write!(writer, "{percent_encoded_length}\t{base64_encoded_length}")?; + if let Source::File(path) = source { + write!(writer, "\t{}", path.to_string_lossy())?; + } + writeln!(writer)?; + } else { + data_url.write_to(&mut *writer)?; + writer.write_all(b"\n")?; } } - (None, None) => data_url.set_data(input), - } - - if let Some(fragment) = &options.fragment { - data_url.set_fragment(Some(fragment)); - } - - write_output(&options.output_file, |writer| { - data_url.write_to(&mut *writer)?; - writer.write_all(b"\n") + Ok(()) }); } } diff --git a/tests/cli.rs b/tests/cli.rs index 2a987ca..740c206 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -8,6 +8,34 @@ fn data_file(name: &str) -> PathBuf { .collect() } +/// An empty directory of its own for each test, removed when dropped +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> TempDir { + let path = std::env::temp_dir().join(format!("dataurl-test-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + fn file(&self, name: &str, contents: &[u8]) -> String { + let path = self.0.join(name); + std::fs::write(&path, contents).unwrap(); + path.to_str().unwrap().to_string() + } + + fn path(&self, name: &str) -> String { + self.0.join(name).to_str().unwrap().to_string() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + fn run(args: &[&str], stdin: &[u8]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_dataurl")) .args(args) @@ -98,6 +126,37 @@ mod basic { mod encode { use super::*; + #[test] + fn conflicting_options() { + for (args, message) in [ + ( + &["-b", "-s", "x"][..], + "'--base64' and '--shortest' can't be used together", + ), + ( + &["-d", "--size", "x"], + "'--size' can't be used together with '--decode'", + ), + ( + &["-O", "dir", "x"], + "'--output-dir' only works together with '--decode'", + ), + ( + &["-d", "-o", "file", "-O", "dir", "x"], + "'--output-file' and '--output-dir' can't be used together", + ), + (&["-i", "-", "-i", "-"], "STDIN can only be read once"), + ] { + let output = run(args, b""); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with(&format!("error: {message}\n")), + "{stderr}" + ); + assert_eq!(output.status.code(), Some(2)); + } + } + #[test] fn empty_argument() { assert_success(&run(&[""], b""), b"data:,\n"); @@ -123,6 +182,57 @@ mod encode { ); } + #[test] + fn shortest() { + assert_success(&run(&["-s", "abc"], b""), b"data:,abc\n"); + assert_success( + &run(&["--shortest"], &[0xFF; 30]), + b"data:;base64,////////////////////////////////////////\n", + ); + } + + #[test] + fn minimal_escaping() { + assert_success(&run(&["-m", ""], b""), b"data:,%3Ca b='c'%3E\n"); + assert_success( + &run(&["--minimal", ""], b""), + b"data:,%3Ca b='c'%3E\n", + ); + assert_success(&run(&[""], b""), b"data:,%3Ca%20b=%27c%27%3E\n"); + } + + #[test] + fn size() { + // "data:,a%20b" and "data:;base64,YSBi" + assert_success(&run(&["--size", "a b"], b""), b"11\t17\n"); + // Other options count too + assert_success(&run(&["--size", "-m", "-f", "x", "a b"], b""), b"11\t19\n"); + } + + #[test] + fn size_of_files() { + let dir = TempDir::new("size"); + let a = dir.file("a.txt", b"a b"); + let png = data_file("lizard.png"); + let png = png.to_str().unwrap(); + + let output = run(&["--size", "-i", &a, "-i", png], b""); + let stdout = String::from_utf8(output.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines[0], format!("21\t27\t{a}")); + + let encoded = |args: &[&str]| run(args, b"").stdout.len() - 1; + assert_eq!( + lines[1], + format!( + "{}\t{}\t{png}", + encoded(&["-i", png]), + encoded(&["-b", "-i", png]) + ) + ); + assert_eq!(lines.len(), 2); + } + #[test] fn media_type() { assert_success( @@ -244,6 +354,42 @@ mod encode { .starts_with(b"data:application/octet-stream;base64,iVBOR")); } + #[test] + fn several_input_files() { + let dir = TempDir::new("several-inputs"); + let css = dir.file("style.css", b"a{}"); + let txt = dir.file("notes", b"x y"); + assert_success( + &run(&["-i", &css, "-i", "-", "--input-file", &txt], b""), + b"data:text/css,a{}\ndata:image/svg+xml,%3Csvg/%3E\ndata:,x%20y\n", + ); + } + + #[test] + fn several_input_files_with_options() { + let dir = TempDir::new("several-inputs-options"); + let a = dir.file("a.txt", b"a"); + let b = dir.file("b.txt", b"b"); + assert_success( + &run(&["-b", "-t", "x/y", "-f", "z", "-i", &a, "-i", &b], b""), + b"data:x/y;base64,YQ==#z\ndata:x/y;base64,Yg==#z\n", + ); + } + + #[test] + fn unreadable_one_of_several_input_files() { + let dir = TempDir::new("several-inputs-unreadable"); + let a = dir.file("a.txt", b"a"); + let output_file = dir.path("out.txt"); + let output = run( + &["-i", &a, "-i", &dir.path("missing"), "-o", &output_file], + b"", + ); + assert!(!output.status.success()); + // What came before the error still gets written + assert_eq!(std::fs::read(&output_file).unwrap(), b"data:text/plain,a\n"); + } + #[test] fn media_type_not_detected_for_argument() { assert_success( @@ -317,6 +463,97 @@ mod decode { std::fs::remove_file(path).unwrap(); } + #[test] + fn several_inputs_need_output_dir() { + let dir = TempDir::new("decode-several-no-dir"); + let a = dir.file("a.txt", b"data:,a"); + let output = run(&["-d", "-i", &a, "-i", &a], b""); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.starts_with("error: decoding more than one input needs '--output-dir'\n")); + assert_eq!(output.status.code(), Some(2)); + } + + #[test] + fn output_dir() { + let dir = TempDir::new("decode-output-dir"); + let png = std::fs::read(data_file("lizard.png")).unwrap(); + let png_url = format!( + "data:image/png;base64,{}", + run(&["-b", "-t", "x/x"], &png).stdout[16..] + .iter() + .map(|&b| b as char) + .collect::() + .trim_end() + ); + + let inputs = [ + dir.file("logo.txt", png_url.as_bytes()), + // Already has the right extension before ".txt" + dir.file("icon.png.txt", png_url.as_bytes()), + // Unknown media type, but the data has a PNG signature + dir.file("other", png_url.replace("image/png", "x/x").as_bytes()), + dir.file("page.url", b"data:text/html,

hi

\n"), + dir.file("blob", b"data:application/x-unknown,%00%01"), + dir.file("note", b"data:,plain"), + // Same name as before, so it gets numbered + dir.file("note.txt", b"data:,another"), + ]; + let out = dir.path("out/nested"); + + let mut args = vec!["-d", "--output-dir", &out]; + for input in &inputs { + args.extend(["-i", input]); + } + let output = run(&args, b""); + + let expected = [ + ("logo.png", &png[..]), + ("icon.png", &png), + ("other.png", &png), + ("page.html", b"

hi

"), + ("blob.bin", b"\x00\x01"), + ("note.txt", b"plain"), + ("note-2.txt", b"another"), + ]; + let mut expected_stdout = String::new(); + for (name, contents) in expected { + let path = std::path::Path::new(&out).join(name); + assert_eq!(std::fs::read(&path).unwrap(), contents, "{name}"); + expected_stdout.push_str(&format!("{}\n", path.display())); + } + assert_success(&output, expected_stdout.as_bytes()); + } + + #[test] + fn output_dir_from_argument_and_stdin() { + let dir = TempDir::new("decode-output-dir-stdin"); + let out = dir.path("out"); + let path = std::path::Path::new(&out).join("data.css"); + assert_success( + &run(&["-d", "-O", &out, "data:text/css,a{}"], b""), + format!("{}\n", path.display()).as_bytes(), + ); + assert_eq!(std::fs::read(&path).unwrap(), b"a{}"); + + let path = std::path::Path::new(&out).join("data.svg"); + assert_success( + &run(&["-d", "-O", &out], b"data:image/svg+xml,"), + format!("{}\n", path.display()).as_bytes(), + ); + assert_eq!(std::fs::read(&path).unwrap(), b""); + } + + #[test] + fn invalid_one_of_several_inputs() { + let dir = TempDir::new("decode-invalid-input"); + let bad = dir.file("bad.txt", b"data:x"); + let output = run(&["-d", "-O", &dir.path("out"), "-i", &bad], b""); + assert_failure( + &output, + &format!("error: {bad}: data URL has no comma separating metadata from data\n"), + ); + } + #[test] fn empty_input() { assert_failure(&run(&["-d", ""], b""), "error: not a data URL\n");