diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index b9f57e8175..9a99949d04 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f78df23339e3136e8b6e9148a509508633448dd2 +bbfb0de0dc9fa06130a231eb10c64f60238d1bbd diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 61ec59be75..3b43a04ad1 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -422,11 +422,7 @@ async fn sign_expression( let mut tx = user_db.begin(&authed).await?; // Truncate expression for resource field if too long (max 255 chars) - let resource = if request.expression.len() > 200 { - format!("{}...", &request.expression[..200]) - } else { - request.expression.clone() - }; + let resource = windmill_common::utils::truncate_with_ellipsis(&request.expression, 200); audit_log( &mut *tx, diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 33056f59fa..21b1f56389 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::db::DB; use crate::error::Result; +use crate::utils::truncate_with_ellipsis; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] #[sqlx(type_name = "MESSAGE_TYPE", rename_all = "lowercase")] @@ -51,12 +52,8 @@ pub async fn get_or_create_conversation_with_id( return Ok(existing); } - // Truncate title to 25 char characters max - let title = if title.len() > 25 { - format!("{}...", &title[..25]) - } else { - title.to_string() - }; + // Truncate title to 25 characters max + let title = truncate_with_ellipsis(title, 25); // Create new conversation with provided ID let conversation = sqlx::query_as!( diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index abf1c1b662..0c8f8e4f4a 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1077,12 +1077,7 @@ pub fn format_setting_value(key: &str, value: &serde_json::Value) -> String { }; } let value = mask_nested_sensitive(key, value); - let s = value.to_string(); - if s.len() > 200 { - format!("{}...", &s[..197]) - } else { - s - } + crate::utils::truncate_with_ellipsis(&value.to_string(), 197) } /// Extract the expiry timestamp from a license key JSON value. diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 7b1f2a13f9..2bd6f1b204 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -1364,10 +1364,45 @@ pub fn strip_json_nul(serialized: &str) -> Cow<'_, str> { Cow::Owned(String::from_utf8(out).expect("removing a NUL escape preserves valid UTF-8")) } +/// Prefix of `s` holding at most `max_chars` characters. +/// Slicing by byte index (`&s[..n]`) panics when `n` lands inside a multibyte character. +pub fn truncate_chars(s: &str, max_chars: usize) -> &str { + match s.char_indices().nth(max_chars) { + Some((byte_idx, _)) => &s[..byte_idx], + None => s, + } +} + +/// Keep at most `max_chars` characters of `s`, appending `...` when anything was dropped — +/// so a truncated result is `max_chars + 3` characters long, not `max_chars`. +pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String { + let truncated = truncate_chars(s, max_chars); + if truncated.len() < s.len() { + format!("{}...", truncated) + } else { + s.to_string() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn truncate_handles_multibyte_at_boundary() { + // Byte 25 of this string falls inside a 2-byte 'а'; naive `&s[..25]` would panic. + let cyrillic = "а".repeat(30); + assert_eq!(truncate_chars(&cyrillic, 25), "а".repeat(25)); + assert_eq!( + truncate_with_ellipsis(&cyrillic, 25), + format!("{}...", "а".repeat(25)) + ); + assert_eq!(truncate_with_ellipsis("ааааааааааааа", 25), "ааааааааааааа"); + assert_eq!(truncate_chars("abcd", 3), "abc"); + assert_eq!(truncate_with_ellipsis("abc", 3), "abc"); + assert_eq!(truncate_with_ellipsis("abcd", 3), "abc..."); + } + // The 6-char JSON escape for U+0000: backslash + "u0000". Written via an // escaped backslash so no literal NUL byte ever appears in this source. const NUL_ESC: &str = "\\u0000";