fix: truncate strings on char boundaries to avoid panics on multibyte input (#10390)

* fix: truncate strings on char boundaries to avoid panics on multibyte input

* fix: add borrowed truncate_chars helper and pin ee ref for audit fix

* docs: clarify truncate_with_ellipsis length contract

* chore: update ee-repo-ref to bbfb0de0dc9fa06130a231eb10c64f60238d1bbd

This commit updates the EE repository reference after PR #690 was merged in windmill-ee-private.

Previous ee-repo-ref: de15aeff12daf457711f5b981de691418484535c

New ee-repo-ref: bbfb0de0dc9fa06130a231eb10c64f60238d1bbd

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-07-28 18:29:45 +02:00
committed by GitHub
parent 773a428ad0
commit faa2aaf214
5 changed files with 41 additions and 18 deletions
+1 -1
View File
@@ -1 +1 @@
f78df23339e3136e8b6e9148a509508633448dd2
bbfb0de0dc9fa06130a231eb10c64f60238d1bbd
+1 -5
View File
@@ -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,
@@ -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!(
@@ -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.
+35
View File
@@ -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";