Skip to content

Commit efdf45f

Browse files
authored
feat: firmware vendor transition detection and QUIC_ fork exemption
AI Generated pull-request Detects when a BBL/BFL file spans multiple firmware vendors (e.g. Betaflight sessions recorded before a board was reflashed to EmuFlight) and corrects per-session output filename prefixes accordingly. - Print WARNING with per-vendor session ranges when firmware transitions are detected in a single file - Correct output filename prefix when a session's vendor differs from the BBL filename prefix (e.g. Betaflight sessions in an EMUF_* file → BTFL_*) - Prevent duplicate prefixes (BTFL_BTFL_* / EMUF_BTFL_* forbidden) - Add FORK_REVISION_MAP to exempt known Betaflight forks (QUIC_) from false-positive renames — Quicksilver intentionally writes Betaflight revision headers for Blackbox Explorer compatibility - Add sanitize_base_name_override() to prevent path traversal on public library API - Add vendor_name_for_prefix() as single source of truth for prefix→display-name mapping
1 parent 91bab03 commit efdf45f

11 files changed

Lines changed: 277 additions & 21 deletions

examples/csv_export.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ fn main() -> anyhow::Result<()> {
5454

5555
// Export to CSV
5656
println!("\nExporting to CSV...");
57-
export_to_csv(&log, Path::new(&input_file), &export_opts)?;
57+
export_to_csv(&log, Path::new(&input_file), &export_opts, None)?;
5858
println!("✓ CSV export complete");
5959

6060
Ok(())

examples/event_export.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ fn main() -> anyhow::Result<()> {
4747
1, // total_logs (assuming single log for this example)
4848
&log.event_frames,
4949
&export_opts,
50+
None,
5051
)?;
5152
println!("✓ Event export complete");
5253
println!(" Exported {} events", log.event_frames.len());

examples/export_demo.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ fn main() -> Result<()> {
8484
// Export CSV
8585
println!("=== Exporting Data ===");
8686
println!("Exporting CSV files...");
87-
export_to_csv(&log, input_path, &export_opts)?;
87+
export_to_csv(&log, input_path, &export_opts, None)?;
8888
println!("✓ CSV export complete");
8989

9090
// Compute log index once (log_number is 1-based)
@@ -109,6 +109,7 @@ fn main() -> Result<()> {
109109
&log.home_coordinates,
110110
&export_opts,
111111
log.header.log_start_datetime.as_deref(),
112+
None,
112113
)?;
113114
println!("✓ GPX export complete");
114115
} else {
@@ -127,6 +128,7 @@ fn main() -> Result<()> {
127128
log.total_logs,
128129
&log.event_frames,
129130
&export_opts,
131+
None,
130132
)?;
131133
println!("✓ Event export complete");
132134

examples/gpx_export.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ fn main() -> anyhow::Result<()> {
4848
&log.home_coordinates,
4949
&export_opts,
5050
log.header.log_start_datetime.as_deref(),
51+
None,
5152
)?;
5253
println!("✓ GPX export complete");
5354
println!(" Exported {} GPS coordinates", log.gps_coordinates.len());

examples/multi_export.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ fn main() -> anyhow::Result<()> {
6767

6868
// CSV Export (always works)
6969
println!("Exporting CSV...");
70-
export_to_csv(&log, Path::new(&input_file), &export_opts)?;
70+
export_to_csv(&log, Path::new(&input_file), &export_opts, None)?;
7171
println!("✓ CSV export complete");
7272

7373
// Compute log index once (log_number is 1-based)
@@ -84,6 +84,7 @@ fn main() -> anyhow::Result<()> {
8484
&log.home_coordinates,
8585
&export_opts,
8686
log.header.log_start_datetime.as_deref(),
87+
None,
8788
)?;
8889
println!(
8990
"✓ GPX export complete ({} coordinates)",
@@ -107,6 +108,7 @@ fn main() -> anyhow::Result<()> {
107108
log.total_logs,
108109
&log.event_frames,
109110
&export_opts,
111+
None,
110112
)?;
111113
println!(
112114
"✓ Event export complete ({} events)",

examples/multi_flight_export.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ fn main() -> anyhow::Result<()> {
6060

6161
// Export to CSV
6262
println!(" Exporting to CSV...");
63-
export_to_csv(&log, Path::new(&input_file), &export_opts)?;
63+
export_to_csv(&log, Path::new(&input_file), &export_opts, None)?;
6464

6565
// Display export result with optional flight number suffix
6666
if log.total_logs > 1 {

src/conversion.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,7 @@ fn parse_datetime_to_epoch(datetime_str: &str) -> Option<u64> {
343343

344344
// Convert to days since epoch (simplified, doesn't handle all edge cases)
345345
let days = ymd_to_days(year, month, day)?;
346-
let local_secs =
347-
(days as u64) * 86400 + (hour as u64) * 3600 + (minute as u64) * 60 + (second as u64);
346+
let local_secs = days * 86400 + (hour as u64) * 3600 + (minute as u64) * 60 + (second as u64);
348347

349348
// Convert local time to UTC by subtracting the offset
350349
// If offset is +02:00, local time is 2 hours ahead of UTC, so subtract 2 hours

src/export.rs

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
417512
pub 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
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
//! force_export: false,
3737
//! };
3838
//! let log = parse_bbl_file(Path::new("flight.BBL"), export_options.clone(), false).unwrap();
39-
//! let report = export_to_csv(&log, Path::new("flight.BBL"), &export_options).unwrap();
39+
//! let report = export_to_csv(&log, Path::new("flight.BBL"), &export_options, None).unwrap();
4040
//! if let Some(path) = report.csv_path {
4141
//! println!("Exported to: {}", path.display());
4242
//! }

0 commit comments

Comments
 (0)