From 712e718d15f9e7950079d5b432e873d5afb65c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 4 May 2026 22:08:27 +0000 Subject: [PATCH] fix: unicode issues with bt sync pull --- src/sync.rs | 36 ++++++++++++++++++++++++++++++++++++ src/traces.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/sync.rs b/src/sync.rs index 1aa2138b..1550807c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -2607,6 +2607,34 @@ fn build_root_spans_query( parts.join(" | ") } +fn parse_duration_to_seconds(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + bail!("duration cannot be empty"); + } + if let Ok(seconds) = trimmed.parse::() { + return Ok(seconds); + } + + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; + let value: u64 = num_str + .trim() + .parse() + .with_context(|| format!("invalid duration '{input}'"))?; + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), + }; + Ok(value.saturating_mul(multiplier)) +} + fn build_time_filter_clause(window: &str, extra_filter: Option<&str>) -> Result { let seconds = parse_duration_to_seconds(window)?; let time_clause = format!("created >= NOW() - INTERVAL {seconds} SECOND"); @@ -4268,6 +4296,14 @@ fn spinner_bar(message: &str) -> ProgressBar { mod tests { use super::*; + #[test] + fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } + #[test] fn push_checkpoint_line_offset_advances_only_after_commit() { let mut state = diff --git a/src/traces.rs b/src/traces.rs index 6708a521..4cd03e99 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -5031,6 +5031,34 @@ fn print_span_text(item: Option<&Map>) { } } +fn parse_duration_to_seconds(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + bail!("duration cannot be empty"); + } + if let Ok(seconds) = trimmed.parse::() { + return Ok(seconds); + } + + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; + let value: u64 = num_str + .trim() + .parse() + .with_context(|| format!("invalid duration '{input}'"))?; + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), + }; + Ok(value.saturating_mul(multiplier)) +} + fn build_base_filter_clause( since: Option<&str>, window: &str, @@ -6156,6 +6184,22 @@ mod tests { } } + #[test] + fn parse_duration_to_seconds_supports_units() { + assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90); + assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900); + assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200); + assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); + } + + #[test] + fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } + #[test] fn build_base_filter_clause_uses_window_or_since() { let from_window = build_base_filter_clause(None, "1h", Some("metadata.model IS NOT NULL"))