From bdcf8c9b2ac7832271a759efb18d1978939f4388 Mon Sep 17 00:00:00 2001 From: nerdCopter <56646290+nerdCopter@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:10:36 -0500 Subject: [PATCH 1/5] feat: add support for directory input to process BBL files recursively - Allow passing directories as input; all BBL/BFL files within (including subdirectories) are processed - Update help text to document directory support (directories only find BBL/BFL, not TXT) - Maintain glob pattern support for backward compatibility - Add recursive directory scanning with consistent file ordering - Preserve explicit TXT file support when specified individually - Simplify error messages for better user experience --- src/main.rs | 194 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 56 deletions(-) diff --git a/src/main.rs b/src/main.rs index 47367c2..8988138 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,100 @@ use clap::{Arg, Command}; use glob::glob; use semver::Version; use std::collections::HashMap; +use std::fs; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; + +/// Expand input paths to a list of BBL files. +/// If a path is a file, add it directly (will be filtered later for BBL/BFL/TXT extension). +/// If a path is a directory, recursively find all BBL files within it. +/// If a path contains glob patterns, expand them first. +fn expand_input_paths(input_paths: &[String]) -> Result> { + let mut bbl_files = Vec::new(); + + for input_path_str in input_paths { + // Check if this is a glob pattern + if input_path_str.contains('*') || input_path_str.contains('?') { + match glob(input_path_str) { + Ok(glob_iter) => { + let collected = glob_iter.collect::, _>>(); + match collected { + Ok(paths) => { + for path in paths { + if let Some(path_str) = path.to_str() { + let sub_result = expand_input_paths(&[path_str.to_string()])?; + bbl_files.extend(sub_result); + } + } + } + Err(e) => { + eprintln!("Error expanding glob pattern '{input_path_str}': {e}"); + } + } + } + Err(e) => { + eprintln!("Invalid glob pattern '{input_path_str}': {e}"); + } + } + continue; + } + + let input_path = Path::new(input_path_str); + + if input_path.is_file() { + // It's a file, add it directly + bbl_files.push(input_path_str.clone()); + } else if input_path.is_dir() { + // It's a directory, find all BBL files recursively + let mut dir_bbl_files = find_bbl_files_in_dir(input_path)?; + bbl_files.append(&mut dir_bbl_files); + } else { + // Path doesn't exist or isn't accessible + eprintln!( + "Warning: Path not found or not accessible: {}", + input_path_str + ); + } + } + + Ok(bbl_files) +} + +/// Recursively find all BBL files in a directory +fn find_bbl_files_in_dir(dir_path: &Path) -> Result> { + let mut bbl_files = Vec::new(); + + if !dir_path.is_dir() { + return Ok(bbl_files); + } + + let entries = fs::read_dir(dir_path)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Recursively search subdirectories + let mut sub_bbl_files = find_bbl_files_in_dir(&path)?; + bbl_files.append(&mut sub_bbl_files); + } else if path.is_file() { + // Check if it's a BBL file (only BBL for directories, not TXT) + if let Some(extension) = path.extension() { + let ext_lower = extension.to_string_lossy().to_ascii_lowercase(); + if ext_lower == "bbl" || ext_lower == "bfl" { + if let Some(path_str) = path.to_str() { + bbl_files.push(path_str.to_string()); + } + } + } + } + } + + // Sort the files for consistent ordering + bbl_files.sort(); + Ok(bbl_files) +} #[derive(Debug, Clone)] struct FieldDefinition { @@ -264,7 +356,7 @@ fn build_command() -> Command { .about("Read and parse BBL blackbox log files. Exports to CSV by default (optionally GPX/JSON).") .arg( Arg::new("files") - .help("BBL files to parse (.BBL, .BFL, .TXT extensions supported, case-insensitive, supports globbing)") + .help("BBL files or directories to parse. Files: .BBL, .BFL, .TXT extensions supported. Directories: recursively finds .BBL/.BFL files only. Case-insensitive, supports globbing.") .required(false) .num_args(1..) .index(1), @@ -344,69 +436,59 @@ fn main() -> Result<()> { println!("Input patterns: {file_patterns:?}"); } + // Expand input paths (files and directories) to a list of BBL files + let input_files = match expand_input_paths( + &file_patterns + .iter() + .map(|s| s.to_string()) + .collect::>(), + ) { + Ok(files) => files, + Err(e) => { + eprintln!("Error expanding input paths: {e}"); + std::process::exit(1); + } + }; + + if input_files.is_empty() { + eprintln!("Error: No BBL files found in the specified input paths."); + std::process::exit(1); + } + // Collect all valid file paths let mut valid_paths = Vec::new(); - for pattern in &file_patterns { + for file_path_str in &input_files { + let path = PathBuf::from(file_path_str); + if debug { - println!("Processing pattern: {pattern}"); + println!("Checking file: {path:?}"); } - let paths: Vec<_> = if pattern.contains('*') || pattern.contains('?') { - match glob(pattern) { - Ok(glob_iter) => { - let collected = glob_iter.collect::, _>>(); - match collected { - Ok(paths) => { - if debug { - println!("Glob pattern '{pattern}' matched {} files", paths.len()); - } - paths - } - Err(e) => { - eprintln!("Error expanding glob pattern '{pattern}': {e}"); - continue; - } - } - } - Err(e) => { - eprintln!("Invalid glob pattern '{pattern}': {e}"); - continue; - } - } - } else { - vec![Path::new(pattern).to_path_buf()] - }; - - for path in paths { - if debug { - println!("Checking file: {path:?}"); - } - - if !path.exists() { - eprintln!("Warning: File does not exist: {path:?}"); - continue; - } + if !path.exists() { + eprintln!("Warning: File does not exist: {path:?}"); + continue; + } - let valid_extension = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| { - let ext_lower = ext.to_ascii_lowercase(); - ext_lower == "bbl" || ext_lower == "bfl" || ext_lower == "txt" - }) - .unwrap_or(false); + // Check file extension + let valid_extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + let ext_lower = ext.to_ascii_lowercase(); + ext_lower == "bbl" || ext_lower == "bfl" || ext_lower == "txt" + }) + .unwrap_or(false); - if !valid_extension { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("none"); - eprintln!("Warning: Skipping file with unsupported extension '{ext}': {path:?}"); - continue; - } + if !valid_extension { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("none"); + eprintln!("Warning: Skipping file with unsupported extension '{ext}': {path:?}"); + continue; + } - if debug { - println!("Added valid file: {path:?}"); - } - valid_paths.push(path); + if debug { + println!("Added valid file: {path:?}"); } + valid_paths.push(path); } if debug { From e731a3025bd7bbc1f08694a8401994568d6b9f28 Mon Sep 17 00:00:00 2001 From: nerdCopter <56646290+nerdCopter@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:34:54 -0500 Subject: [PATCH 2/5] fix: robust recursive input path expansion with symlink cycle and stack overflow protection; propagate glob errors; correct visited set logic --- src/main.rs | 173 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 135 insertions(+), 38 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8988138..81837d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,16 +4,37 @@ use anyhow::{Context, Result}; use clap::{Arg, Command}; use glob::glob; use semver::Version; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +/// Maximum recursion depth to prevent stack overflow +const MAX_RECURSION_DEPTH: usize = 100; + /// Expand input paths to a list of BBL files. /// If a path is a file, add it directly (will be filtered later for BBL/BFL/TXT extension). /// If a path is a directory, recursively find all BBL files within it. /// If a path contains glob patterns, expand them first. -fn expand_input_paths(input_paths: &[String]) -> Result> { +fn expand_input_paths( + input_paths: &[String], + visited: &mut HashSet, +) -> Result> { + expand_input_paths_with_depth(input_paths, visited, 0) +} + +/// Internal function with depth tracking for recursion protection +fn expand_input_paths_with_depth( + input_paths: &[String], + visited: &mut HashSet, + depth: usize, +) -> Result> { + if depth > MAX_RECURSION_DEPTH { + return Err(anyhow::anyhow!( + "Maximum recursion depth exceeded ({})", + MAX_RECURSION_DEPTH + )); + } let mut bbl_files = Vec::new(); for input_path_str in input_paths { @@ -26,18 +47,26 @@ fn expand_input_paths(input_paths: &[String]) -> Result> { Ok(paths) => { for path in paths { if let Some(path_str) = path.to_str() { - let sub_result = expand_input_paths(&[path_str.to_string()])?; + let sub_result = expand_input_paths_with_depth( + &[path_str.to_string()], + visited, + depth + 1, + )?; bbl_files.extend(sub_result); } } } Err(e) => { - eprintln!("Error expanding glob pattern '{input_path_str}': {e}"); + return Err(anyhow::Error::new(e).context(format!( + "Error expanding glob pattern '{}'", + input_path_str + ))); } } } Err(e) => { - eprintln!("Invalid glob pattern '{input_path_str}': {e}"); + return Err(anyhow::Error::new(e) + .context(format!("Invalid glob pattern '{}'", input_path_str))); } } continue; @@ -45,54 +74,120 @@ fn expand_input_paths(input_paths: &[String]) -> Result> { let input_path = Path::new(input_path_str); - if input_path.is_file() { - // It's a file, add it directly - bbl_files.push(input_path_str.clone()); - } else if input_path.is_dir() { - // It's a directory, find all BBL files recursively - let mut dir_bbl_files = find_bbl_files_in_dir(input_path)?; - bbl_files.append(&mut dir_bbl_files); - } else { - // Path doesn't exist or isn't accessible - eprintln!( - "Warning: Path not found or not accessible: {}", - input_path_str - ); + match input_path.canonicalize() { + Ok(canonical_path) => { + if canonical_path.is_file() { + // It's a file, add it directly + if let Some(path_str) = canonical_path.to_str() { + bbl_files.push(path_str.to_string()); + } + } else if canonical_path.is_dir() { + // It's a directory, find all BBL files recursively + // Don't add to visited here since find_bbl_files_in_dir_with_depth will handle it + let mut dir_bbl_files = + find_bbl_files_in_dir_with_depth(&canonical_path, visited, depth + 1)?; + bbl_files.append(&mut dir_bbl_files); + } else { + // Path doesn't exist or isn't accessible + eprintln!( + "Warning: Path not found or not accessible: {}", + input_path_str + ); + } + } + Err(e) => { + eprintln!( + "Warning: Failed to canonicalize path '{}': {}", + input_path_str, e + ); + // Skip this path + continue; + } } } Ok(bbl_files) } -/// Recursively find all BBL files in a directory -fn find_bbl_files_in_dir(dir_path: &Path) -> Result> { +/// Recursively find all BBL files in a directory, protecting against symlink cycles and depth overflow +fn find_bbl_files_in_dir_with_depth( + dir_path: &Path, + visited: &mut HashSet, + depth: usize, +) -> Result> { + if depth > MAX_RECURSION_DEPTH { + return Err(anyhow::anyhow!( + "Maximum recursion depth exceeded in directory traversal ({})", + MAX_RECURSION_DEPTH + )); + } + let mut bbl_files = Vec::new(); - if !dir_path.is_dir() { - return Ok(bbl_files); - } + match dir_path.canonicalize() { + Ok(canonical_dir) => { + if visited.contains(&canonical_dir) { + // Already visited, skip to avoid cycles + return Ok(bbl_files); + } + visited.insert(canonical_dir.clone()); + + if !canonical_dir.is_dir() { + return Ok(bbl_files); + } - let entries = fs::read_dir(dir_path)?; + let entries = fs::read_dir(&canonical_dir)?; - for entry in entries { - let entry = entry?; - let path = entry.path(); + for entry in entries { + let entry = entry?; + let path = entry.path(); - if path.is_dir() { - // Recursively search subdirectories - let mut sub_bbl_files = find_bbl_files_in_dir(&path)?; - bbl_files.append(&mut sub_bbl_files); - } else if path.is_file() { - // Check if it's a BBL file (only BBL for directories, not TXT) - if let Some(extension) = path.extension() { - let ext_lower = extension.to_string_lossy().to_ascii_lowercase(); - if ext_lower == "bbl" || ext_lower == "bfl" { - if let Some(path_str) = path.to_str() { - bbl_files.push(path_str.to_string()); + match path.canonicalize() { + Ok(canonical_path) => { + if visited.contains(&canonical_path) { + continue; + } + visited.insert(canonical_path.clone()); + + if canonical_path.is_dir() { + // Recursively search subdirectories + let mut sub_bbl_files = find_bbl_files_in_dir_with_depth( + &canonical_path, + visited, + depth + 1, + )?; + bbl_files.append(&mut sub_bbl_files); + } else if canonical_path.is_file() { + // Check if it's a BBL file (only BBL for directories, not TXT) + if let Some(extension) = canonical_path.extension() { + let ext_lower = extension.to_string_lossy().to_ascii_lowercase(); + if ext_lower == "bbl" || ext_lower == "bfl" { + if let Some(path_str) = canonical_path.to_str() { + bbl_files.push(path_str.to_string()); + } + } + } + } + } + Err(e) => { + eprintln!( + "Warning: Failed to canonicalize path in dir '{}': {}", + path.display(), + e + ); + continue; } } } } + Err(e) => { + eprintln!( + "Warning: Failed to canonicalize directory '{}': {}", + dir_path.display(), + e + ); + return Ok(bbl_files); + } } // Sort the files for consistent ordering @@ -437,11 +532,13 @@ fn main() -> Result<()> { } // Expand input paths (files and directories) to a list of BBL files + let mut visited = HashSet::new(); let input_files = match expand_input_paths( &file_patterns .iter() .map(|s| s.to_string()) .collect::>(), + &mut visited, ) { Ok(files) => files, Err(e) => { From 5963cb18a5afe51d9b003dc241790e90dfc0c52f Mon Sep 17 00:00:00 2001 From: nerdCopter <56646290+nerdCopter@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:45:08 -0500 Subject: [PATCH 3/5] fix: improve I/O error resilience in directory traversal - handle permission denied and other I/O errors gracefully by warning and continuing rather than exiting --- src/main.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 81837d1..55d6b40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,10 +136,30 @@ fn find_bbl_files_in_dir_with_depth( return Ok(bbl_files); } - let entries = fs::read_dir(&canonical_dir)?; + let entries = match fs::read_dir(&canonical_dir) { + Ok(entries) => entries, + Err(e) => { + eprintln!( + "Warning: Cannot read directory '{}': {}", + canonical_dir.display(), + e + ); + return Ok(bbl_files); + } + }; for entry in entries { - let entry = entry?; + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + eprintln!( + "Warning: Cannot read entry in directory '{}': {}", + canonical_dir.display(), + e + ); + continue; + } + }; let path = entry.path(); match path.canonicalize() { From 8a45dd10037dc05bd4979d048076ef2b27ec2e4f Mon Sep 17 00:00:00 2001 From: nerdCopter <56646290+nerdCopter@users.noreply.github.com> Date: Fri, 10 Oct 2025 07:16:41 -0500 Subject: [PATCH 4/5] docs: clarify help text and error message for file type handling (BBL/BFL/TXT) --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 55d6b40..c035313 100644 --- a/src/main.rs +++ b/src/main.rs @@ -471,7 +471,7 @@ fn build_command() -> Command { .about("Read and parse BBL blackbox log files. Exports to CSV by default (optionally GPX/JSON).") .arg( Arg::new("files") - .help("BBL files or directories to parse. Files: .BBL, .BFL, .TXT extensions supported. Directories: recursively finds .BBL/.BFL files only. Case-insensitive, supports globbing.") + .help("BBL files or directories to parse. Direct file paths: .BBL, .BFL, .TXT extensions supported. Directories: recursively finds .BBL/.BFL files only (TXT files must be specified directly). Case-insensitive, supports globbing.") .required(false) .num_args(1..) .index(1), @@ -568,7 +568,7 @@ fn main() -> Result<()> { }; if input_files.is_empty() { - eprintln!("Error: No BBL files found in the specified input paths."); + eprintln!("Error: No valid BBL/BFL/TXT files found in the specified input paths."); std::process::exit(1); } From 70555ff513b63c7e4f076dfddddffb8670d5e66a Mon Sep 17 00:00:00 2001 From: nerdCopter <56646290+nerdCopter@users.noreply.github.com> Date: Fri, 10 Oct 2025 07:54:40 -0500 Subject: [PATCH 5/5] fix: deterministic glob ordering, dedup input files, and prevent duplicate file processing - Sort glob-expanded paths for stable cross-platform ordering - Use visited set to dedupe direct and scanned files - Dedupe final input list while preserving order All mandatory checks (fmt, clippy, test, build) passed. --- src/main.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index c035313..1135239 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,8 @@ fn expand_input_paths_with_depth( Ok(glob_iter) => { let collected = glob_iter.collect::, _>>(); match collected { - Ok(paths) => { + Ok(mut paths) => { + paths.sort(); // deterministic ordering for path in paths { if let Some(path_str) = path.to_str() { let sub_result = expand_input_paths_with_depth( @@ -77,9 +78,11 @@ fn expand_input_paths_with_depth( match input_path.canonicalize() { Ok(canonical_path) => { if canonical_path.is_file() { - // It's a file, add it directly - if let Some(path_str) = canonical_path.to_str() { - bbl_files.push(path_str.to_string()); + // It's a file; dedupe using visited + if visited.insert(canonical_path.clone()) { + if let Some(path_str) = canonical_path.to_str() { + bbl_files.push(path_str.to_string()); + } } } else if canonical_path.is_dir() { // It's a directory, find all BBL files recursively @@ -553,7 +556,7 @@ fn main() -> Result<()> { // Expand input paths (files and directories) to a list of BBL files let mut visited = HashSet::new(); - let input_files = match expand_input_paths( + let mut input_files = match expand_input_paths( &file_patterns .iter() .map(|s| s.to_string()) @@ -567,6 +570,12 @@ fn main() -> Result<()> { } }; + // Dedupe while preserving original order + { + let mut seen = std::collections::HashSet::new(); + input_files.retain(|p| seen.insert(p.clone())); + } + if input_files.is_empty() { eprintln!("Error: No valid BBL/BFL/TXT files found in the specified input paths."); std::process::exit(1);