@@ -83,6 +83,95 @@ fn extract_base_name(input_path: &Path) -> &str {
8383 . unwrap_or ( "blackbox" )
8484}
8585
86+ /// Sanitize a `base_name_override` value for safe use in file path construction.
87+ /// Returns the final path component only, preventing directory traversal via `../`
88+ /// segments or absolute paths. Returns `None` if the value is empty or ends in `..`.
89+ fn sanitize_base_name_override ( base_name_override : Option < & str > ) -> Option < & str > {
90+ let raw = base_name_override?;
91+ Path :: new ( raw) . file_name ( ) . and_then ( |s| s. to_str ( ) )
92+ }
93+
94+ /// Return a human-readable vendor name for a known filename prefix.
95+ /// Falls back to `"Unknown"` for unrecognised prefixes.
96+ pub fn vendor_name_for_prefix ( prefix : & str ) -> & ' static str {
97+ match prefix {
98+ "EMUF_" => "EmuFlight" ,
99+ "BTFL_" => "Betaflight" ,
100+ "INAV_" => "iNav" ,
101+ "QUIC_" => "Quicksilver" ,
102+ _ => "Unknown" ,
103+ }
104+ }
105+
106+ /// Known firmware vendor filename prefixes mapped to their revision keywords.
107+ /// To add a new firmware: append `("PREFIX_", "keyword")` where keyword is a
108+ /// lowercase substring of that firmware's `H Firmware revision:` header value.
109+ /// For forks that share a base firmware's revision string, add an entry here
110+ /// for filename detection and add a corresponding entry to `FORK_REVISION_MAP`.
111+ const KNOWN_FIRMWARE_PREFIXES : & [ ( & str , & str ) ] = & [
112+ ( "EMUF_" , "emuflight" ) ,
113+ ( "BTFL_" , "betaflight" ) ,
114+ ( "INAV_" , "inav" ) ,
115+ ( "QUIC_" , "quicksilver" ) , // Quicksilver; see FORK_REVISION_MAP for rename exemption
116+ ] ;
117+
118+ /// Fork firmware prefixes that intentionally report another vendor's revision string.
119+ /// Sessions in files with these prefixes are NOT renamed even when the revision string
120+ /// maps to a different canonical prefix — the mismatch is by design.
121+ ///
122+ /// Each entry is `(file_prefix, reported_canonical_prefix)`.
123+ /// To add a fork: append `("FORK_", "BASE_")` where BASE_ is what
124+ /// `firmware_prefix_for_revision` returns for that fork's revision headers.
125+ const FORK_REVISION_MAP : & [ ( & str , & str ) ] = & [
126+ // Quicksilver writes Betaflight revision headers for Blackbox Explorer compatibility.
127+ ( "QUIC_" , "BTFL_" ) ,
128+ ] ;
129+
130+ /// Return the canonical filename prefix for a firmware revision string (e.g. `"BTFL_"`),
131+ /// or `None` if the vendor is not recognised.
132+ pub fn firmware_prefix_for_revision ( revision : & str ) -> Option < & ' static str > {
133+ let rev_lower = revision. trim ( ) . to_lowercase ( ) ;
134+ for & ( prefix, keyword) in KNOWN_FIRMWARE_PREFIXES {
135+ if rev_lower. contains ( keyword) {
136+ return Some ( prefix) ;
137+ }
138+ }
139+ None
140+ }
141+
142+ fn detect_bbl_filename_prefix ( stem : & str ) -> Option < & ' static str > {
143+ KNOWN_FIRMWARE_PREFIXES
144+ . iter ( )
145+ . find ( |& & ( prefix, _) | stem. starts_with ( prefix) )
146+ . map ( |& ( prefix, _) | prefix)
147+ }
148+
149+ /// Compute a corrected base name for a session whose firmware vendor differs from
150+ /// the BBL filename prefix. Returns `Some(corrected_stem)` when a replacement is
151+ /// needed; `None` when the vendors match, either is unrecognised, or the file prefix
152+ /// is a known fork that intentionally uses a different base firmware's revision string.
153+ ///
154+ /// # Examples
155+ /// - `EMUF_BLACKBOX_LOG_...BBL` + `EmuFlight 0.4.3` → `None` (matches)
156+ /// - `EMUF_BLACKBOX_LOG_...BBL` + `Betaflight 2025.12.0-beta` → `Some("BTFL_BLACKBOX_LOG_...")`
157+ /// - `QUIC_Twiglet_...BFL` + `Betaflight 4.3.0` → `None` (fork exemption)
158+ pub fn corrected_session_base_name ( bbl_path : & Path , firmware_revision : & str ) -> Option < String > {
159+ let stem = bbl_path. file_stem ( ) ?. to_str ( ) ?;
160+ let bbl_prefix = detect_bbl_filename_prefix ( stem) ?;
161+ let session_prefix = firmware_prefix_for_revision ( firmware_revision) ?;
162+ if bbl_prefix == session_prefix {
163+ return None ;
164+ }
165+ // Known fork: the revision mismatch is by design — do not rename
166+ if FORK_REVISION_MAP
167+ . iter ( )
168+ . any ( |& ( fp, bp) | fp == bbl_prefix && bp == session_prefix)
169+ {
170+ return None ;
171+ }
172+ Some ( format ! ( "{}{}" , session_prefix, & stem[ bbl_prefix. len( ) ..] ) )
173+ }
174+
86175/// Helper to compute export file paths with consistent naming across all export types.
87176/// Ensures CLI status messages match actual filenames written by export functions.
88177///
@@ -91,6 +180,7 @@ fn extract_base_name(input_path: &Path) -> &str {
91180/// * `export_options` - Export configuration with optional output directory
92181/// * `log_number` - 1-based log number (for .NN suffix when multiple logs)
93182/// * `total_logs` - Total number of logs in the file
183+ /// * `base_name_override` - Optional replacement stem (e.g. from `corrected_session_base_name`)
94184///
95185/// # Returns
96186/// Tuple of (csv_path, headers_path, gpx_path, event_path) using consistent naming
@@ -99,13 +189,15 @@ pub fn compute_export_paths(
99189 export_options : & ExportOptions ,
100190 log_number : usize ,
101191 total_logs : usize ,
192+ base_name_override : Option < & str > ,
102193) -> (
103194 std:: path:: PathBuf ,
104195 std:: path:: PathBuf ,
105196 std:: path:: PathBuf ,
106197 std:: path:: PathBuf ,
107198) {
108- let base_name = extract_base_name ( input_path) ;
199+ let base_name = sanitize_base_name_override ( base_name_override)
200+ . unwrap_or_else ( || extract_base_name ( input_path) ) ;
109201
110202 let output_dir = if let Some ( ref dir) = export_options. output_dir {
111203 std:: path:: Path :: new ( dir)
@@ -192,8 +284,10 @@ pub fn export_to_csv(
192284 log : & BBLLog ,
193285 input_path : & Path ,
194286 export_options : & ExportOptions ,
287+ base_name_override : Option < & str > ,
195288) -> Result < ExportReport > {
196- let base_name = extract_base_name ( input_path) ;
289+ let base_name = sanitize_base_name_override ( base_name_override)
290+ . unwrap_or_else ( || extract_base_name ( input_path) ) ;
197291
198292 let output_dir = if let Some ( ref dir) = export_options. output_dir {
199293 Path :: new ( dir)
@@ -414,6 +508,7 @@ fn export_flight_data_to_csv(log: &BBLLog, output_path: &Path) -> Result<()> {
414508/// For very large GPS traces, the `log_start_datetime` is parsed via `generate_gpx_timestamp()`
415509/// on each trackpoint. Future optimization: consider caching the parsed base epoch once per log
416510/// to avoid repeated parsing overhead when exporting thousands of GPS points.
511+ #[ allow( clippy:: too_many_arguments) ]
417512pub fn export_to_gpx (
418513 input_path : & Path ,
419514 log_index : usize ,
@@ -422,14 +517,20 @@ pub fn export_to_gpx(
422517 home_coordinates : & [ GpsHomeCoordinate ] ,
423518 export_options : & ExportOptions ,
424519 log_start_datetime : Option < & str > ,
520+ base_name_override : Option < & str > ,
425521) -> Result < ExportReport > {
426522 if gps_coordinates. is_empty ( ) {
427523 return Ok ( ExportReport :: default ( ) ) ;
428524 }
429525
430526 // Use compute_export_paths to ensure consistent naming with CSV exports
431- let ( _, _, gpx_path, _) =
432- compute_export_paths ( input_path, export_options, log_index + 1 , total_logs) ;
527+ let ( _, _, gpx_path, _) = compute_export_paths (
528+ input_path,
529+ export_options,
530+ log_index + 1 ,
531+ total_logs,
532+ base_name_override,
533+ ) ;
433534
434535 // Create output directory if it doesn't exist (match export_to_csv behavior)
435536 if let Some ( parent) = gpx_path. parent ( ) {
@@ -505,14 +606,20 @@ pub fn export_to_event(
505606 total_logs : usize ,
506607 event_frames : & [ EventFrame ] ,
507608 export_options : & ExportOptions ,
609+ base_name_override : Option < & str > ,
508610) -> Result < ExportReport > {
509611 if event_frames. is_empty ( ) {
510612 return Ok ( ExportReport :: default ( ) ) ;
511613 }
512614
513615 // Use compute_export_paths to ensure consistent naming with CSV exports
514- let ( _, _, _, event_path) =
515- compute_export_paths ( input_path, export_options, log_index + 1 , total_logs) ;
616+ let ( _, _, _, event_path) = compute_export_paths (
617+ input_path,
618+ export_options,
619+ log_index + 1 ,
620+ total_logs,
621+ base_name_override,
622+ ) ;
516623
517624 // Create output directory if it doesn't exist (match export_to_csv behavior)
518625 if let Some ( parent) = event_path. parent ( ) {
@@ -571,6 +678,7 @@ mod tests {
571678 home_coords,
572679 & export_opts,
573680 None ,
681+ None ,
574682 ) ?;
575683
576684 // Read back the generated GPX file
@@ -858,6 +966,7 @@ mod tests {
858966 & home_coords,
859967 & export_opts,
860968 None ,
969+ None ,
861970 ) ;
862971 assert ! (
863972 result. is_ok( ) ,
@@ -915,4 +1024,76 @@ mod tests {
9151024
9161025 Ok ( ( ) )
9171026 }
1027+
1028+ #[ test]
1029+ fn test_firmware_prefix_for_revision ( ) {
1030+ assert_eq ! (
1031+ firmware_prefix_for_revision( "EmuFlight 0.4.3" ) ,
1032+ Some ( "EMUF_" )
1033+ ) ;
1034+ assert_eq ! (
1035+ firmware_prefix_for_revision( "EMUFLIGHT 0.4.3" ) ,
1036+ Some ( "EMUF_" )
1037+ ) ;
1038+ assert_eq ! (
1039+ firmware_prefix_for_revision( "Betaflight 4.5.0" ) ,
1040+ Some ( "BTFL_" )
1041+ ) ;
1042+ assert_eq ! (
1043+ firmware_prefix_for_revision( "Betaflight 2025.12.0-beta (abc) STM32H743" ) ,
1044+ Some ( "BTFL_" )
1045+ ) ;
1046+ assert_eq ! ( firmware_prefix_for_revision( "INAV 7.1.2" ) , Some ( "INAV_" ) ) ;
1047+ assert_eq ! ( firmware_prefix_for_revision( "" ) , None ) ;
1048+ assert_eq ! ( firmware_prefix_for_revision( "Unknown firmware" ) , None ) ;
1049+ }
1050+
1051+ #[ test]
1052+ fn test_corrected_session_base_name_matching_vendor ( ) {
1053+ let path = std:: path:: Path :: new ( "/logs/EMUF_BLACKBOX_LOG_QUAD_20260531.BBL" ) ;
1054+ assert_eq ! ( corrected_session_base_name( path, "EmuFlight 0.4.3" ) , None ) ;
1055+
1056+ let path = std:: path:: Path :: new ( "/logs/BTFL_BLACKBOX_LOG_QUAD_20260531.BBL" ) ;
1057+ assert_eq ! ( corrected_session_base_name( path, "Betaflight 4.5.0" ) , None ) ;
1058+ }
1059+
1060+ #[ test]
1061+ fn test_corrected_session_base_name_mismatched_vendor ( ) {
1062+ let path = std:: path:: Path :: new ( "/logs/EMUF_BLACKBOX_LOG_QUAD_20260531.BBL" ) ;
1063+ assert_eq ! (
1064+ corrected_session_base_name( path, "Betaflight 2025.12.0-beta" ) ,
1065+ Some ( "BTFL_BLACKBOX_LOG_QUAD_20260531" . to_string( ) )
1066+ ) ;
1067+
1068+ let path = std:: path:: Path :: new ( "/logs/BTFL_BLACKBOX_LOG_QUAD_20260531.BBL" ) ;
1069+ assert_eq ! (
1070+ corrected_session_base_name( path, "EmuFlight 0.4.3" ) ,
1071+ Some ( "EMUF_BLACKBOX_LOG_QUAD_20260531" . to_string( ) )
1072+ ) ;
1073+ }
1074+
1075+ #[ test]
1076+ fn test_corrected_session_base_name_unknown_prefix ( ) {
1077+ // BBL without a known prefix — no correction regardless of firmware
1078+ let path = std:: path:: Path :: new ( "/logs/BLACKBOX_LOG_QUAD_20260531.BBL" ) ;
1079+ assert_eq ! ( corrected_session_base_name( path, "EmuFlight 0.4.3" ) , None ) ;
1080+ assert_eq ! ( corrected_session_base_name( path, "Betaflight 4.5.0" ) , None ) ;
1081+ }
1082+
1083+ #[ test]
1084+ fn test_corrected_session_base_name_fork_exemption ( ) {
1085+ // Quicksilver (QUIC_) intentionally writes Betaflight revision headers — must NOT rename
1086+ let path = std:: path:: Path :: new ( "/logs/QUIC_Twiglet_2026-06-09_file_0.bfl" ) ;
1087+ assert_eq ! (
1088+ corrected_session_base_name( path, "Betaflight 4.3.0" ) ,
1089+ None ,
1090+ "QUIC_ files with Betaflight revision must not be renamed to BTFL_"
1091+ ) ;
1092+ // Sanity: an actual mismatch on a non-fork prefix still renames
1093+ let path = std:: path:: Path :: new ( "/logs/EMUF_BLACKBOX_LOG_20260531.BBL" ) ;
1094+ assert_eq ! (
1095+ corrected_session_base_name( path, "Betaflight 4.3.0" ) ,
1096+ Some ( "BTFL_BLACKBOX_LOG_20260531" . to_string( ) )
1097+ ) ;
1098+ }
9181099}
0 commit comments