2022-01-22 16:41:24 +00:00
|
|
|
// SPDX-FileCopyrightText: 2021-2022 Lynnesbian
|
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
2021-10-05 14:24:08 +00:00
|
|
|
|
2021-02-14 16:20:48 +00:00
|
|
|
// fif - a command-line tool for detecting and optionally correcting files with incorrect extensions.
|
2021-02-05 05:57:21 +00:00
|
|
|
|
2021-04-06 15:47:40 +00:00
|
|
|
#![forbid(unsafe_code)]
|
2021-10-05 15:41:08 +00:00
|
|
|
#![warn(
|
|
|
|
trivial_casts,
|
|
|
|
unused_lifetimes,
|
|
|
|
unused_qualifications,
|
|
|
|
missing_copy_implementations,
|
|
|
|
unused_allocation
|
|
|
|
)]
|
2021-04-06 15:47:40 +00:00
|
|
|
|
2021-10-04 13:33:48 +00:00
|
|
|
use std::io::{stdin, stdout, BufWriter, Write};
|
2021-04-20 05:20:10 +00:00
|
|
|
use std::process::exit;
|
2021-02-14 16:20:48 +00:00
|
|
|
|
2021-09-25 08:55:50 +00:00
|
|
|
use cfg_if::cfg_if;
|
2022-01-01 03:28:45 +00:00
|
|
|
use clap::Parser;
|
2021-08-28 07:59:04 +00:00
|
|
|
use fif::files::{scan_directory, scan_from_walkdir};
|
2021-10-04 18:45:05 +00:00
|
|
|
use fif::formats::{self, Format};
|
|
|
|
use fif::parameters::{self, OutputFormat, Prompt};
|
2021-09-24 14:53:35 +00:00
|
|
|
use fif::utils::{os_name, CLAP_LONG_VERSION};
|
2021-10-04 10:22:15 +00:00
|
|
|
use itertools::Itertools;
|
2021-09-24 08:11:25 +00:00
|
|
|
use log::{debug, error, info, trace, warn, Level};
|
2021-02-06 03:24:13 +00:00
|
|
|
|
2021-02-28 09:47:18 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
mod tests;
|
2021-02-04 11:22:19 +00:00
|
|
|
|
2021-02-28 14:06:05 +00:00
|
|
|
#[doc(hidden)]
|
2021-10-04 14:18:42 +00:00
|
|
|
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
|
2021-02-28 12:20:15 +00:00
|
|
|
fn main() {
|
|
|
|
let args: parameters::Parameters = parameters::Parameters::parse();
|
|
|
|
|
2021-07-01 08:52:53 +00:00
|
|
|
let mut builder = env_logger::Builder::new();
|
2021-02-28 12:20:15 +00:00
|
|
|
builder
|
2021-10-04 16:12:16 +00:00
|
|
|
.filter_level(args.get_verbosity()) // set default log level
|
2021-07-01 08:52:53 +00:00
|
|
|
.parse_default_env() // set log level from RUST_LOG
|
|
|
|
.parse_env("FIF_LOG") // set log level from FIF_LOG
|
|
|
|
.format(|buf, r| {
|
|
|
|
let mut style = buf.default_level_style(r.level());
|
|
|
|
// use bold for warnings and errors
|
|
|
|
style.set_bold(r.level() <= Level::Warn);
|
|
|
|
// only use the first character of the log level name
|
|
|
|
let abbreviation = style.value(r.level().to_string().chars().next().unwrap());
|
|
|
|
// e.g. [D] Debug message
|
|
|
|
writeln!(buf, "[{}] {}", abbreviation, r.args())
|
|
|
|
})
|
2021-02-28 12:20:15 +00:00
|
|
|
.init();
|
|
|
|
|
2021-06-14 08:23:49 +00:00
|
|
|
trace!(
|
|
|
|
"fif {}, running on {} {}",
|
2021-09-24 14:53:35 +00:00
|
|
|
CLAP_LONG_VERSION.as_str(),
|
2021-06-14 08:23:49 +00:00
|
|
|
std::env::consts::ARCH,
|
|
|
|
os_name()
|
|
|
|
);
|
2021-02-28 12:20:15 +00:00
|
|
|
|
2021-05-08 00:10:51 +00:00
|
|
|
debug!("Iterating directory: {:?}", args.dir);
|
2021-02-28 12:20:15 +00:00
|
|
|
|
|
|
|
let extensions = args.extensions();
|
2021-04-27 10:25:41 +00:00
|
|
|
let excludes = args.excluded_extensions();
|
2021-02-28 12:20:15 +00:00
|
|
|
|
2021-04-27 10:25:41 +00:00
|
|
|
if let Some(extensions) = &extensions {
|
|
|
|
debug!("Checking files with extensions: {:?}", extensions);
|
|
|
|
} else if let Some(excludes) = &excludes {
|
|
|
|
debug!("Skipping files with extensions: {:?}", excludes);
|
|
|
|
} else {
|
|
|
|
debug!("Checking files regardless of extensions");
|
|
|
|
}
|
2021-02-28 12:20:15 +00:00
|
|
|
|
2021-06-18 05:36:05 +00:00
|
|
|
let entries = match scan_directory(&args.dir, extensions.as_ref(), excludes.as_ref(), &args.get_scan_opts()) {
|
2021-02-28 12:20:15 +00:00
|
|
|
// no need to log anything for fatal errors - fif will already have printed something obvious like
|
|
|
|
// "[ERROR] /fake/path: No such file or directory (os error 2)". we can assume that if this has happened, the dir
|
|
|
|
// given as input doesn't exist or is otherwise unreadable.
|
2021-06-18 05:36:05 +00:00
|
|
|
None => exit(exitcode::NOINPUT),
|
|
|
|
Some(e) => e,
|
|
|
|
};
|
2021-02-28 12:20:15 +00:00
|
|
|
|
|
|
|
if entries.is_empty() {
|
|
|
|
warn!("No files matching requested options found.");
|
|
|
|
exit(exitcode::OK);
|
|
|
|
}
|
|
|
|
|
|
|
|
trace!("Found {} items to check", entries.len());
|
|
|
|
|
2021-09-25 08:55:50 +00:00
|
|
|
cfg_if! {
|
|
|
|
if #[cfg(feature = "multi-threaded")] {
|
|
|
|
let use_threads = args.jobs != 1;
|
|
|
|
|
|
|
|
if use_threads {
|
|
|
|
// 0 is a special case - it should be understood to mean "all available host CPUs"
|
|
|
|
let jobs = if args.jobs == 0 { num_cpus::get() } else { args.jobs };
|
|
|
|
|
|
|
|
// set up the global thread pool with the requested number of threads
|
|
|
|
rayon::ThreadPoolBuilder::new().num_threads(jobs).build_global().unwrap();
|
|
|
|
trace!("Multithreading enabled, using {} threads", jobs);
|
|
|
|
} else {
|
|
|
|
trace!("Multithreading disabled at runtime");
|
|
|
|
}
|
|
|
|
|
|
|
|
} else { // `multi-threading` feature disabled
|
|
|
|
let use_threads = false;
|
|
|
|
trace!("Multithreading disabled at compile time");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-04 10:22:15 +00:00
|
|
|
let (findings, errors) = scan_from_walkdir(&entries, args.canonical_paths, use_threads);
|
2021-04-28 13:19:04 +00:00
|
|
|
trace!("Scanning complete");
|
|
|
|
|
2021-10-04 10:22:15 +00:00
|
|
|
if findings.is_empty() && errors.is_empty() {
|
2021-02-28 12:20:15 +00:00
|
|
|
info!("All files have valid extensions!");
|
2021-07-24 06:20:49 +00:00
|
|
|
exit(exitcode::OK);
|
2021-02-28 12:20:15 +00:00
|
|
|
}
|
|
|
|
|
2021-10-04 10:22:15 +00:00
|
|
|
// remove files that already have the correct extension, then sort - first by whether or not they have a
|
|
|
|
// recommended_extension() (with None before Some(ext)), then by filename
|
|
|
|
let findings = findings
|
|
|
|
.into_iter()
|
|
|
|
.filter(|f| !f.valid)
|
|
|
|
.sorted_unstable()
|
|
|
|
.collect_vec();
|
|
|
|
// sort errors (File errors before Mime errors), then log a warning for each error
|
|
|
|
let errors = errors
|
|
|
|
.into_iter()
|
|
|
|
.sorted_unstable()
|
|
|
|
.map(|e| {
|
|
|
|
warn!("{}", &e);
|
|
|
|
e
|
|
|
|
})
|
|
|
|
.collect_vec();
|
2021-03-25 14:28:03 +00:00
|
|
|
|
2021-10-04 10:22:15 +00:00
|
|
|
if args.fix {
|
2021-10-04 14:18:42 +00:00
|
|
|
fn ask(message: &str) -> bool {
|
2021-10-04 13:33:48 +00:00
|
|
|
let mut buf = String::with_capacity(1);
|
|
|
|
print!("{} [y/N] ", message);
|
|
|
|
|
|
|
|
// flush stdout to ensure message is displayed
|
|
|
|
stdout().flush().expect("Failed to flush stdout");
|
|
|
|
|
|
|
|
if let Err(e) = stdin().read_line(&mut buf) {
|
|
|
|
// something went wrong while reading input - just exit
|
|
|
|
error!("{}", e);
|
|
|
|
exit(exitcode::IOERR)
|
|
|
|
}
|
2021-10-04 14:18:42 +00:00
|
|
|
buf.starts_with('y') || buf.starts_with('Y')
|
2021-10-04 13:33:48 +00:00
|
|
|
}
|
|
|
|
|
2021-10-04 14:18:42 +00:00
|
|
|
let prompt = args.prompt.unwrap_or(Prompt::Error);
|
2021-10-04 13:33:48 +00:00
|
|
|
|
2021-10-13 13:53:55 +00:00
|
|
|
let mut renamed = 0_u32; // files that were successfully renamed
|
|
|
|
let mut skipped = 0_u32; // files that were skipped over without trying to rename
|
|
|
|
let mut failed = 0_u32; // files that fif failed to rename - e.g. files that are exclusively locked
|
|
|
|
|
2021-10-04 13:33:48 +00:00
|
|
|
for f in findings {
|
|
|
|
if let Some(rename_to) = f.recommended_path() {
|
|
|
|
let will_rename = {
|
|
|
|
if !args.overwrite && rename_to.exists() {
|
|
|
|
// handles: --prompt never, --prompt error, --prompt always
|
|
|
|
// user didn't specify --overwrite, and the destination exists
|
|
|
|
info!("Not renaming {:#?}: Target {:#?} exists", f.file, rename_to);
|
|
|
|
false
|
|
|
|
} else if prompt == Prompt::Never {
|
|
|
|
// handles: --prompt never --overwrite
|
|
|
|
// user specified --prompt never in conjunction with --overwrite, so always rename
|
|
|
|
true
|
2021-10-04 14:18:42 +00:00
|
|
|
} else if prompt == Prompt::Error || ask(&*format!("Rename {:#?} to {:#?}?", &f.file, &rename_to)) {
|
2021-10-04 13:33:48 +00:00
|
|
|
// handles: --prompt error --overwrite, --prompt always --overwrite [y]
|
|
|
|
// if the target exists, prompt before renaming; otherwise, just rename
|
2021-10-04 14:18:42 +00:00
|
|
|
!rename_to.exists() || ask(&*format!("Destination {:#?} already exists, overwrite?", rename_to))
|
2021-10-04 13:33:48 +00:00
|
|
|
} else {
|
|
|
|
// handles: --prompt always --overwrite [n]
|
|
|
|
// user was prompted and replied "no"
|
|
|
|
false
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-10-04 14:18:42 +00:00
|
|
|
if !will_rename {
|
2021-10-13 13:53:55 +00:00
|
|
|
skipped += 1;
|
2021-10-04 14:18:42 +00:00
|
|
|
continue;
|
|
|
|
}
|
2021-10-04 13:33:48 +00:00
|
|
|
|
|
|
|
loop {
|
2021-10-13 13:53:55 +00:00
|
|
|
// until file is renamed successfully
|
2021-10-04 13:33:48 +00:00
|
|
|
match std::fs::rename(&f.file, &rename_to) {
|
|
|
|
Ok(_) => {
|
|
|
|
info!("Renamed {:#?} -> {:#?}", f.file, rename_to);
|
2021-10-13 13:53:55 +00:00
|
|
|
renamed += 1;
|
2021-10-04 13:33:48 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
Err(e) => {
|
|
|
|
warn!("Couldn't rename {:#?} to {:#?}: {:#?}", f.file, rename_to, e);
|
|
|
|
// if the user passed --prompt never, continue to the next file
|
|
|
|
// otherwise, prompt user to retry move, retrying until the rename succeeds or they respond "N"
|
2021-10-04 14:18:42 +00:00
|
|
|
if prompt == Prompt::Never || !ask(&*format!("Error while renaming file: {:#?}. Try again?", e)) {
|
2021-10-13 13:53:55 +00:00
|
|
|
failed += 1;
|
2021-10-04 13:33:48 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// no recommended name :c
|
2021-10-04 14:18:42 +00:00
|
|
|
info!("No known extension for file {:#?} of type {}", f.file, f.mime);
|
2021-10-13 13:53:55 +00:00
|
|
|
skipped += 1;
|
2021-10-04 13:33:48 +00:00
|
|
|
}
|
|
|
|
}
|
2021-10-13 13:53:55 +00:00
|
|
|
|
|
|
|
info!(
|
|
|
|
"Processed {} files: Renamed {}, skipped {}, failed to rename {}",
|
|
|
|
renamed + skipped + failed,
|
|
|
|
renamed,
|
|
|
|
skipped,
|
|
|
|
failed
|
|
|
|
);
|
2021-10-04 10:22:15 +00:00
|
|
|
} else {
|
|
|
|
let mut buffered_stdout = BufWriter::new(stdout());
|
|
|
|
|
2021-11-05 16:43:08 +00:00
|
|
|
if match args.output_format {
|
2021-10-04 18:45:05 +00:00
|
|
|
// TODO: simplify this to something like formats::write_all(args.output_format, ...)
|
2021-10-04 10:22:15 +00:00
|
|
|
OutputFormat::Sh => formats::Shell.write_all(&mut buffered_stdout, &findings, &errors),
|
|
|
|
OutputFormat::PowerShell => formats::PowerShell.write_all(&mut buffered_stdout, &findings, &errors),
|
|
|
|
#[cfg(feature = "json")]
|
|
|
|
OutputFormat::Json => formats::Json.write_all(&mut buffered_stdout, &findings, &errors),
|
|
|
|
OutputFormat::Text => formats::Text.write_all(&mut buffered_stdout, &findings, &errors),
|
2021-11-22 22:38:43 +00:00
|
|
|
}
|
|
|
|
.is_err()
|
|
|
|
{
|
2021-10-04 10:22:15 +00:00
|
|
|
error!("Failed to write to stdout.");
|
|
|
|
exit(exitcode::IOERR);
|
|
|
|
}
|
2021-11-22 22:38:43 +00:00
|
|
|
|
2021-10-04 10:22:15 +00:00
|
|
|
if buffered_stdout.flush().is_err() {
|
|
|
|
error!("Failed to flush stdout.");
|
|
|
|
exit(exitcode::IOERR);
|
|
|
|
}
|
2021-04-20 08:52:49 +00:00
|
|
|
}
|
|
|
|
|
2021-02-28 12:20:15 +00:00
|
|
|
debug!("Done");
|
|
|
|
}
|