fif/src/formats.rs

344 lines
10 KiB
Rust

//! The various formats that [fif](crate) can output to.
use std::ffi::OsStr;
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use cfg_if::cfg_if;
use snailquote::escape;
use crate::findings::ScanError;
use crate::parameters::VERSION;
use crate::{Findings, BACKEND};
use itertools::Itertools;
/// A macro for creating an array of `Writable`s without needing to pepper your code with `into()`s.
/// # Usage
/// ```
/// let f = std::io::stdout();
/// // Instead of...
/// smart_write(f, &["hello".into(), Writable::Newline]);
/// // ...just use:
/// smart_write(f, writables!["hello", Newline]);
/// ```
#[macro_export]
macro_rules! writables {
[$($args:tt),+] => {
&[$(writables!(@do $args),)*]
};
(@do Newline) => {
$crate::formats::Writable::Newline
};
(@do Space) => {
$crate::formats::Writable::Space
};
(@do $arg:expr) => {
$arg.into()
}
}
#[macro_export]
/// Does the same thing as [writables], but adds a Newline to the end.
macro_rules! writablesln {
[$($args:tt),+] => {
&[$(writables!(@do $args),)* writables!(@do Newline)]
};
}
#[doc(hidden)]
type Entries<'a> = [Result<Findings<'a>, ScanError<'a>>];
#[derive(Debug, PartialEq)]
pub enum Writable<'a> {
String(&'a str),
Path(&'a Path),
Space,
Newline,
}
// the lifetime of a lifetime
impl<'a> From<&'a str> for Writable<'a> {
fn from(s: &'a str) -> Writable<'a> { Writable::String(s) }
}
impl<'a> From<&'a Path> for Writable<'a> {
fn from(p: &'a Path) -> Writable<'a> { Writable::Path(p) }
}
impl<'a> From<&'a OsStr> for Writable<'a> {
fn from(p: &'a OsStr) -> Writable<'a> { Writable::Path(p.as_ref()) }
}
fn generated_by() -> String { format!("Generated by fif {} ({} backend)", VERSION.unwrap_or("???"), BACKEND) }
fn smart_write<W: Write>(f: &mut W, writeables: &[Writable]) -> io::Result<()> {
// ehhhh
for writeable in writeables {
match writeable {
Writable::Space => write!(f, " ")?,
Writable::Newline => {
cfg_if! {
if #[cfg(windows)] {
write!(f, "\r\n")?;
} else {
writeln!(f,)?;
}
}
}
Writable::String(s) => write!(f, "{}", s)?,
Writable::Path(path) => {
if let Some(path_str) = path.to_str() {
let escaped = escape(path_str);
if escaped.as_ref() == path_str {
// the escaped string is the same as the input - this will occur for inputs like "file.txt" which don't
// need to be escaped. however, it's Best Practice™ to escape such strings anyway, so we prefix/suffix the
// escaped string with single quotes.
write!(f, "'{}'", escaped)?;
} else {
write!(f, "{}", escaped)?;
}
} else {
write!(f, "'")?;
cfg_if! {
if #[cfg(windows)] {
// TODO: implement bonked strings for windows
// something like:
// f.write_all(&*path.as_os_str().encode_wide().collect::<Vec<u16>>())?;
write!(f, "{}", path.as_os_str().to_string_lossy())?;
} else {
f.write_all(&*path.as_os_str().as_bytes())?;
}
}
write!(f, "'")?;
}
}
}
}
Ok(())
}
// TODO: this might need a restructure.
// it would be nice if i didn't have to write a case for every OutputFormat variant that looked like
// OutputFormat::PowerShell => PowerShell::new().write_all(...)
// also, JSON's implementation differs vastly from PowerShell and Shell's implementations. Maybe they shouldn't be
// treated as implementing the same trait, since in that case, the format trait is more of a concept rather than an
// actual definition of behaviour.
// structuring code is *hard*
pub trait Format {
fn new() -> Self;
fn rename<W: Write>(&self, _f: &mut W, _from: &Path, _to: &Path) -> io::Result<()> { unreachable!() }
fn no_known_extension<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()> { unreachable!() }
fn unreadable<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()> { unreachable!() }
fn unknown_type<W: Write>(&self, _f: &mut W, _path: &Path) -> io::Result<()> { unreachable!() }
fn header<W: Write>(&self, _f: &mut W, _entries: &Entries) -> io::Result<()> { unreachable!() }
fn footer<W: Write>(&self, _f: &mut W, _entries: &Entries) -> io::Result<()> { unreachable!() }
fn write_all<W: Write>(&self, f: &mut W, entries: &Entries) -> io::Result<()> {
// TODO: clean this up - it's kinda messy
self.header(f, entries)?;
// output will be generated in the order:
// - files that couldn't be read
// - files with no known mime type
// - files with no known extension
// - files with a known extension
// files that already have a correct extension won't be represented in the output.
// sort errors so unreadable files appear before files with unknown mimetypes - ScanError impls Ord such that
// ScanError::File > ScanError::Mime
let errors = entries.iter().filter_map(|e| e.as_ref().err()).sorted();
// sort files so that files with no known extension come before those with known extensions - None > Some("jpg")
let findings = entries
.iter()
.filter_map(|e| e.as_ref().ok())
.sorted_by(|a, b| b.recommended_extension().cmp(&a.recommended_extension()).reverse());
for error in errors {
match error {
// failed to read the file
ScanError::File(path) => self.unreadable(f, path)?,
// file was read successfully, but we couldn't determine a mimetype
ScanError::Mime(path) => self.unknown_type(f, path)?,
}
}
for finding in findings {
if let Some(ext) = finding.recommended_extension() {
self.rename(f, finding.file, &finding.file.with_extension(ext.as_str()))?;
} else {
self.no_known_extension(f, finding.file)?;
}
}
self.footer(f, entries)
}
}
/// Bourne-Shell compatible script.
pub struct Shell {}
impl Format for Shell {
fn new() -> Self { Self {} }
fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
smart_write(f, writablesln!("mv -v -i -- ", from, Space, to))
}
fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["echo No known extension for ", path])
}
fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["# Failed to read", path])
}
fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["# Failed to detect mime type for ", path])
}
fn header<W: Write>(&self, f: &mut W, _: &Entries) -> io::Result<()> {
smart_write(
f,
writablesln!["#!/usr/bin/env sh", Newline, "# ", (generated_by().as_str())],
)?;
if let Ok(working_directory) = std::env::current_dir() {
smart_write(f, writablesln!["# Run from ", (working_directory.as_path())])?;
}
smart_write(f, writablesln![Newline, "set -e", Newline])
}
fn footer<W: Write>(&self, f: &mut W, _: &Entries) -> io::Result<()> {
smart_write(f, writablesln![Newline, "echo 'Done.'"])
}
}
// PowerShell is a noun, not a type
#[allow(clippy::doc_markdown)]
/// PowerShell script.
pub struct PowerShell {}
impl Format for PowerShell {
fn new() -> Self { Self {} }
fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
// unfortunately there doesn't seem to be an equivalent of sh's `mv -i` -- passing the '-Confirm' flag will prompt
// the user to confirm every single rename, and using Move-Item -Force will always overwrite without prompting.
// there doesn't seem to be a way to rename the file, prompting only if the target already exists.
smart_write(
f,
writablesln!["Rename-Item -Path ", from, " -NewName ", (to.file_name().unwrap())],
)
}
fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(
f,
writablesln![
"Write-Output @'",
Newline,
"No known extension for ",
path,
Newline,
"'@"
],
)
}
fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(
f,
writablesln!["Write-Output @'", Newline, "Failed to read ", path, Newline, "'@"],
)
}
fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["<# Failed to detect mime type for ", path, " #>"])
}
fn header<W: Write>(&self, f: &mut W, _: &Entries) -> io::Result<()> {
smart_write(
f,
writablesln!["#!/usr/bin/env pwsh", Newline, "<# ", (generated_by().as_str()), " #>"],
)?;
if let Ok(working_directory) = std::env::current_dir() {
smart_write(f, writablesln!["<# Run from ", (working_directory.as_path()), " #>"])?;
}
smart_write(f, writables![Newline])
}
fn footer<W: Write>(&self, f: &mut W, _: &Entries) -> io::Result<()> {
smart_write(f, writablesln![Newline, "Write-Output 'Done!'"])
}
}
pub struct Text;
impl Format for Text {
fn new() -> Self { Self {} }
fn rename<W: Write>(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> {
smart_write(f, writablesln![from, " should be renamed to ", to])
}
fn no_known_extension<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["No known extension for ", path])
}
fn unreadable<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["Encountered IO error while accessing ", path])
}
fn unknown_type<W: Write>(&self, f: &mut W, path: &Path) -> io::Result<()> {
smart_write(f, writablesln!["Couldn't determine type for ", path])
}
fn header<W: Write>(&self, f: &mut W, _entries: &Entries) -> io::Result<()> {
smart_write(f, writablesln![(generated_by().as_str()), Newline])
}
fn footer<W: Write>(&self, f: &mut W, entries: &Entries) -> io::Result<()> {
smart_write(
f,
writablesln![Newline, "Processed ", (entries.len().to_string().as_str()), " files"],
)
}
}
#[cfg(feature = "json")]
pub struct Json;
#[cfg(feature = "json")]
impl Format for Json {
fn new() -> Self { Self {} }
fn write_all<W: Write>(&self, f: &mut W, entries: &Entries) -> io::Result<()> {
#[derive(serde::Serialize)]
struct SerdeEntries<'a> {
errors: &'a Vec<&'a ScanError<'a>>,
findings: &'a Vec<&'a Findings<'a>>,
}
let result = serde_json::to_writer_pretty(
f,
&SerdeEntries {
errors: &entries.iter().filter_map(|e| e.as_ref().err()).sorted().collect(),
findings: &entries.iter().filter_map(|f| f.as_ref().ok()).sorted().collect(),
},
);
if let Err(err) = result {
log::error!("Error while serialising: {}", err);
return Err(err.into());
}
Ok(())
}
}