use std::path::{Path as StdPath, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct WalkOptions {
pub(crate) hidden: bool,
pub(crate) respect_ignore: bool,
}
impl Default for WalkOptions {
fn default() -> Self {
Self {
hidden: false,
respect_ignore: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Walked {
pub(crate) files: Vec<PathBuf>,
pub(crate) skipped: usize,
pub(crate) skipped_of_note: Vec<PathBuf>,
}
const VENDORED: [&str; 8] = [
"node_modules",
".vscode-test",
"vendor",
"target",
"dist",
"build",
".venv",
"site-packages",
];
fn is_vendored(path: &StdPath) -> bool {
path.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| VENDORED.contains(&name))
})
}
const CREDENTIAL_NAMES: [&str; 6] = [
".npmrc",
".netrc",
".pgpass",
"credentials",
"id_rsa",
"id_ed25519",
];
const CREDENTIAL_SUFFIXES: [&str; 4] = [".pem", ".key", ".p12", ".pfx"];
fn is_of_note(path: &StdPath) -> bool {
if is_vendored(path) {
return false;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let name = name.to_lowercase();
name == ".env"
|| name.starts_with(".env.")
|| CREDENTIAL_SUFFIXES
.iter()
.any(|suffix| name.ends_with(suffix))
|| CREDENTIAL_NAMES.contains(&name.as_str())
}
pub(crate) fn collect(inputs: &[PathBuf], options: &WalkOptions) -> Result<Walked, String> {
let mut files = Vec::new();
let mut skipped = 0;
let mut skipped_of_note = Vec::new();
for input in inputs {
let metadata =
std::fs::metadata(input).map_err(|error| format!("{}: {error}", input.display()))?;
if metadata.is_file() {
files.push(input.clone());
continue;
}
let walked = walk_directory(input, options)?;
files.extend(walked.files);
skipped += walked.skipped;
skipped_of_note.extend(walked.skipped_of_note);
}
files.sort();
files.dedup();
skipped_of_note.sort();
skipped_of_note.dedup();
Ok(Walked {
files,
skipped,
skipped_of_note,
})
}
fn walk_directory(root: &StdPath, options: &WalkOptions) -> Result<Walked, String> {
let mut permissive = ignore::WalkBuilder::new(root);
permissive
.hidden(false)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.ignore(false)
.parents(false)
.follow_links(false);
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(!options.hidden)
.git_ignore(options.respect_ignore)
.git_global(options.respect_ignore)
.git_exclude(options.respect_ignore)
.ignore(options.respect_ignore)
.parents(options.respect_ignore)
.follow_links(false);
let files = files_under(&mut builder, root)?;
let everything = if options.respect_ignore || !options.hidden {
files_under(&mut permissive, root)?
} else {
files.clone()
};
let scanned: std::collections::HashSet<&PathBuf> = files.iter().collect();
let skipped_of_note = everything
.iter()
.filter(|path| !scanned.contains(path) && is_of_note(path))
.cloned()
.collect();
Ok(Walked {
skipped: everything.len().saturating_sub(files.len()),
skipped_of_note,
files,
})
}
fn files_under(builder: &mut ignore::WalkBuilder, root: &StdPath) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
for entry in builder.build() {
let entry = entry.map_err(|error| format!("{}: {error}", root.display()))?;
if entry.file_type().is_some_and(|kind| kind.is_file()) {
files.push(entry.path().to_path_buf());
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn names(walked: &Walked) -> Vec<String> {
walked
.files
.iter()
.map(|path| {
path.file_name()
.expect("a name")
.to_string_lossy()
.into_owned()
})
.collect()
}
#[test]
fn a_directory_yields_every_file_regardless_of_extension() {
let tree = TempTree::new("walk-all");
tree.write("a.json", "{}");
tree.write("notes.md", "x");
tree.write("config.bak", "x");
tree.write("Makefile", "x");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert_eq!(
names(&walked),
["Makefile", "a.json", "config.bak", "notes.md"]
);
}
#[test]
fn the_order_is_stable() {
let tree = TempTree::new("walk-order");
for name in ["z.env", "a.env", "m.env"] {
tree.write(name, "x");
}
let first = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
let second = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert_eq!(names(&first), ["a.env", "m.env", "z.env"]);
assert_eq!(first, second);
}
#[test]
fn ignored_files_are_skipped_and_counted() {
let tree = TempTree::new("walk-ignore");
tree.mkdir(".git");
tree.write(".gitignore", "secret.env\n");
tree.write("secret.env", "PASSWORD=hunter2hunter2");
tree.write("kept.env", "x");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(names(&walked).contains(&"kept.env".to_string()));
assert!(!names(&walked).contains(&"secret.env".to_string()));
assert!(walked.skipped > 0, "the skip must be countable, not silent");
}
#[test]
fn nothing_is_skipped_when_nothing_is_excluded() {
let tree = TempTree::new("walk-noskip");
tree.write("a.env", "x");
let walked = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
hidden: true,
respect_ignore: false,
},
)
.expect("walks");
assert_eq!(walked.skipped, 0);
}
#[test]
fn hidden_files_are_scanned_on_request() {
let tree = TempTree::new("walk-hidden");
tree.write(".env", "PASSWORD=hunter2hunter2");
let default =
collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(default.files.is_empty());
assert_eq!(default.skipped, 1);
let all = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
hidden: true,
..WalkOptions::default()
},
)
.expect("walks");
assert_eq!(names(&all), [".env"]);
}
#[test]
fn the_default_walk_misses_dotenv_and_says_so() {
let tree = TempTree::new("walk-dotenv");
tree.mkdir(".git");
tree.write(".gitignore", ".env\n");
tree.write(".env", "PASSWORD=hunter2hunter2");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(walked.files.is_empty());
assert!(walked.skipped >= 1, "the miss must be reported as a count");
}
#[test]
fn an_explicitly_named_file_beats_the_ignore_rules() {
let tree = TempTree::new("walk-explicit");
tree.mkdir(".git");
tree.write(".gitignore", ".env\n");
let file = tree.write(".env", "PASSWORD=hunter2hunter2");
let walked = collect(&[file], &WalkOptions::default()).expect("walks");
assert_eq!(names(&walked), [".env"]);
}
#[test]
fn a_skipped_credential_file_is_named_not_just_counted() {
let tree = TempTree::new("walk-ofnote");
tree.mkdir(".git");
tree.write(".gitignore", ".env\nsecrets.pem\nboring.log\n");
tree.write(".env", "PASSWORD=hunter2hunter2");
tree.write("secrets.pem", "-----BEGIN PRIVATE KEY-----");
tree.write("boring.log", "nothing");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
let noted: Vec<String> = walked
.skipped_of_note
.iter()
.map(|p| {
p.file_name()
.expect("a name")
.to_string_lossy()
.into_owned()
})
.collect();
assert!(noted.contains(&".env".to_string()), "{noted:?}");
assert!(noted.contains(&"secrets.pem".to_string()), "{noted:?}");
assert!(!noted.contains(&"boring.log".to_string()), "{noted:?}");
assert!(walked.skipped >= noted.len());
}
#[test]
fn a_credential_name_inside_a_vendored_tree_is_not_of_note() {
let tree = TempTree::new("walk-vendored");
tree.mkdir(".git");
tree.write(".gitignore", "node_modules/\n.vscode-test/\n");
tree.write("node_modules/pkg/.npmrc", "x");
tree.write(".vscode-test/app/.npmrc", "x");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(
walked.skipped_of_note.is_empty(),
"{:?}",
walked.skipped_of_note
);
assert!(walked.skipped >= 2, "they are still counted");
}
#[test]
fn a_missing_input_is_refused_by_name() {
let tree = TempTree::new("walk-missing");
let error =
collect(&[tree.path().join("nope")], &WalkOptions::default()).expect_err("a refusal");
assert!(error.contains("nope"), "{error}");
}
#[test]
fn naming_the_same_file_twice_scans_it_once() {
let tree = TempTree::new("walk-dedupe");
let file = tree.write("a.env", "x");
let walked = collect(&[file.clone(), file], &WalkOptions::default()).expect("walks");
assert_eq!(walked.files.len(), 1);
}
}