From 5b038779924ad59313f71d6cfafe1f7d40ee1c1f Mon Sep 17 00:00:00 2001 From: Andrew White Date: Thu, 23 Jul 2026 18:22:42 -0500 Subject: [PATCH] fix(cli): avoid panic on multi-byte UTF-8 in --since duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_duration_to_ms was moved to commands/common.rs since the original PR was opened, but it still split the last byte of the input with split_at(s.len() - 1), which panics when the final character is multi-byte UTF-8 (e.g. 'openshell logs my-sandbox --since 5€'). Split off the last character using its UTF-8 length instead, so invalid units surface the intended 'unknown duration unit' error. Add regression tests in commands/common.rs. Signed-off-by: Andrew White --- crates/openshell-cli/src/commands/common.rs | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 7fa5cd1fec..e6edb4d33a 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -719,7 +719,10 @@ pub fn parse_duration_to_ms(s: &str) -> Result { if s.is_empty() { return Err(miette::miette!("empty duration string")); } - let (num_str, unit) = s.split_at(s.len() - 1); + // Split off the last character by its UTF-8 length: indexing by byte + // length would panic on multi-byte units (e.g. "5\u{20ac}"). + let last_len = s.chars().last().map_or(0, char::len_utf8); + let (num_str, unit) = s.split_at(s.len() - last_len); let num: i64 = num_str .parse() .map_err(|_| miette::miette!("invalid duration: {s} (expected e.g. 5m, 1h, 30s)"))?; @@ -948,3 +951,28 @@ pub fn scrub_git_env(command: &mut Command) -> &mut Command { } command } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_duration_to_ms_parses_supported_units() { + assert_eq!(parse_duration_to_ms("30s").expect("parse"), 30_000); + assert_eq!(parse_duration_to_ms("5m").expect("parse"), 300_000); + assert_eq!(parse_duration_to_ms("1h").expect("parse"), 3_600_000); + } + + #[test] + fn parse_duration_to_ms_rejects_multi_byte_unit_without_panicking() { + let err = parse_duration_to_ms("5\u{20ac}").expect_err("multi-byte unit should error"); + assert!(err.to_string().contains("unknown duration unit")); + + let err = parse_duration_to_ms("\u{20ac}").expect_err("missing number should error"); + assert!(err.to_string().contains("invalid duration")); + } +}