// SPDX-FileCopyrightText: 2021-2022 Lynnesbian // SPDX-License-Identifier: GPL-3.0-or-later //! Logic for handling the various output formats that fif can output to. #![allow(missing_copy_implementations)] 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::utils::CLAP_LONG_VERSION; use crate::Findings; use crate::String; /// A macro for creating an array of [`Writable`]s without needing to pepper your code with `into()`s. /// # Usage /// ``` /// use crate::fif::writables; /// use crate::fif::formats::{Writable, smart_write}; /// let mut f = std::io::stdout(); /// /// // Instead of... /// smart_write(&mut f, &["hello".into(), Writable::Newline]); /// // ...just use: /// smart_write(&mut f, writables!["hello", Newline]); /// ``` #[macro_export] macro_rules! writables { [$($args:tt),+] => { &[$(writables!(@do $args),)*] }; (@do Newline) => { $crate::formats::Writable::Newline }; (@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)] }; } #[derive(Debug, PartialEq)] pub enum Writable<'a> { String(&'a str), Path(&'a Path), 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 {}", CLAP_LONG_VERSION.as_str()).into() } pub fn smart_write(f: &mut W, writeables: &[Writable]) -> io::Result<()> { // ehhhh for writeable in writeables { match writeable { 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::>())?; write!(f, "{}", path.as_os_str().to_string_lossy())?; } else { f.write_all(&*path.as_os_str().as_bytes())?; } } write!(f, "'")?; } } } } Ok(()) } pub trait FormatSteps { fn rename(&self, _f: &mut W, _from: &Path, _to: &Path) -> io::Result<()>; fn no_known_extension(&self, _f: &mut W, _path: &Path) -> io::Result<()>; fn unreadable(&self, _f: &mut W, _path: &Path) -> io::Result<()>; fn unknown_type(&self, _f: &mut W, _path: &Path) -> io::Result<()>; fn header(&self, _f: &mut W) -> io::Result<()>; fn footer(&self, _f: &mut W) -> io::Result<()>; fn write_steps(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> { self.header(f)?; 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 MIME type ScanError::Mime(path) => self.unknown_type(f, path)?, } } if !errors.is_empty() { // add a blank line between the errors and commands smart_write(f, writables![Newline])?; } for finding in findings { if let Some(name) = finding.recommended_path() { self.rename(f, finding.file.as_path(), &name)?; } else { self.no_known_extension(f, finding.file.as_path())?; } } self.footer(f) } } pub trait Format { fn write_all(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()>; } /// Bourne-Shell compatible script. pub struct Shell; impl Format for Shell { fn write_all(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> { self.write_steps(f, findings, errors) } } impl FormatSteps for Shell { fn rename(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> { smart_write(f, writablesln!("mv -v -i -- ", from, "\t", to)) } fn no_known_extension(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write( f, writablesln!["cat <<- '???'", Newline, "No known extension for ", path, Newline, "???"], ) } fn unreadable(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["# Failed to read", path]) } fn unknown_type(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["# Failed to detect MIME type for ", path]) } fn header(&self, f: &mut W) -> 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(&self, f: &mut W) -> 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 write_all(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> { self.write_steps(f, findings, errors) } } impl FormatSteps for PowerShell { fn rename(&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 -Verbose -Path ", from, " -NewName ", (to.file_name().unwrap())], ) } fn no_known_extension(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write( f, writablesln!["Write-Output @'", Newline, "No known extension for ", path, Newline, "'@"], ) } fn unreadable(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write( f, writablesln!["Write-Output @'", Newline, "Failed to read ", path, Newline, "'@"], ) } fn unknown_type(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["<# Failed to detect MIME type for ", path, " #>"]) } fn header(&self, f: &mut W) -> 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(&self, f: &mut W) -> io::Result<()> { smart_write(f, writablesln![Newline, "Write-Output 'Done!'"]) } } pub struct Text; impl Format for Text { fn write_all(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> { self.write_steps(f, findings, errors) } } impl FormatSteps for Text { fn rename(&self, f: &mut W, from: &Path, to: &Path) -> io::Result<()> { smart_write(f, writablesln![from, " should be renamed to ", to]) } fn no_known_extension(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["No known extension for ", path]) } fn unreadable(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["Encountered IO error while accessing ", path]) } fn unknown_type(&self, f: &mut W, path: &Path) -> io::Result<()> { smart_write(f, writablesln!["Couldn't determine type for ", path]) } fn header(&self, f: &mut W) -> io::Result<()> { smart_write(f, writablesln![(generated_by().as_str()), Newline]) } fn footer(&self, f: &mut W) -> io::Result<()> { smart_write( f, // writablesln![Newline, "Processed ", (entries.len().to_string().as_str()), " files"], &[Writable::Newline], ) } } #[cfg(feature = "json")] pub struct Json; #[cfg(feature = "json")] impl Format for Json { fn write_all(&self, f: &mut W, findings: &[Findings], errors: &[ScanError]) -> io::Result<()> { #[derive(serde::Serialize)] struct SerdeEntries<'a> { errors: &'a [ScanError<'a>], findings: &'a [Findings], } let result = serde_json::to_writer_pretty(f, &SerdeEntries { errors, findings }); if let Err(err) = result { log::error!("Error while serialising: {}", err); return Err(err.into()); } Ok(()) } }