Skip to content

Commit 37b5e39

Browse files
amitksingh1490forge-code-agentautofix-ci[bot]tusharmath
authored
fix: replace byte-index string slicing with char-safe truncation (#2903)
Co-authored-by: ForgeCode <noreply@forgecode.dev> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tushar Mathur <tusharmath@gmail.com>
1 parent 82e9607 commit 37b5e39

File tree

4 files changed

+180
-11
lines changed

4 files changed

+180
-11
lines changed

crates/forge_domain/src/auth/new_types.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ impl AsRef<str> for ApiKey {
3030
/// # Returns
3131
/// * A truncated version of the key for safe display
3232
pub fn truncate_key(key: &str) -> String {
33-
if key.len() <= 20 {
33+
let char_count = key.chars().count();
34+
if char_count <= 20 {
3435
key.to_string()
3536
} else {
36-
format!("{}...{}", &key[..=12], &key[key.len() - 4..])
37+
let prefix: String = key.chars().take(13).collect();
38+
let suffix: String = key.chars().skip(char_count - 4).collect();
39+
format!("{prefix}...{suffix}")
3740
}
3841
}
3942

@@ -153,3 +156,61 @@ pub struct RefreshToken(String);
153156
)]
154157
#[serde(transparent)]
155158
pub struct AccessToken(String);
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use pretty_assertions::assert_eq;
163+
164+
use super::*;
165+
166+
#[test]
167+
fn test_truncate_key_short_key() {
168+
let fixture = "sk-abc123";
169+
let actual = truncate_key(fixture);
170+
let expected = "sk-abc123";
171+
assert_eq!(actual, expected);
172+
}
173+
174+
#[test]
175+
fn test_truncate_key_long_ascii_key() {
176+
let fixture = "sk-1234567890abcdefghijklmnop";
177+
let actual = truncate_key(fixture);
178+
let expected = "sk-1234567890...mnop";
179+
assert_eq!(actual, expected);
180+
}
181+
182+
#[test]
183+
fn test_truncate_key_multibyte_chars_no_panic() {
184+
// Keys with multi-byte UTF-8 characters should not panic
185+
let fixture = "sk-12345678→→→→→→→→→→abcd";
186+
let actual = truncate_key(fixture);
187+
let expected = "sk-12345678→→...abcd";
188+
assert_eq!(actual, expected);
189+
}
190+
191+
#[test]
192+
fn test_truncate_key_emoji_chars_no_panic() {
193+
// Keys with 4-byte emoji characters should not panic
194+
// 25 chars: a(13) + 🔑(8) + b(4) = 25
195+
let fixture = "aaaaaaaaaaaaa🔑🔑🔑🔑🔑🔑🔑🔑bbbb";
196+
let actual = truncate_key(fixture);
197+
let expected = "aaaaaaaaaaaaa...bbbb";
198+
assert_eq!(actual, expected);
199+
}
200+
201+
#[test]
202+
fn test_truncate_key_exactly_20_chars() {
203+
let fixture = "12345678901234567890";
204+
let actual = truncate_key(fixture);
205+
let expected = "12345678901234567890";
206+
assert_eq!(actual, expected);
207+
}
208+
209+
#[test]
210+
fn test_truncate_key_21_chars() {
211+
let fixture = "123456789012345678901";
212+
let actual = truncate_key(fixture);
213+
let expected = "1234567890123...8901";
214+
assert_eq!(actual, expected);
215+
}
216+
}

crates/forge_json_repair/src/schema_coercion.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,9 +451,13 @@ fn extract_array_from_string(s: &str) -> Option<Value> {
451451
}
452452

453453
// Try to find matching closing bracket by parsing incrementally
454-
// Start from the opening bracket and try to parse increasingly longer
455-
// substrings We'll try the json_repair on the extracted portion
456-
for end_idx in (start_idx + 1..=s.len()).rev() {
454+
// Start from the opening bracket and try increasingly shorter substrings.
455+
// We iterate over valid char boundaries to avoid panicking on multi-byte
456+
// UTF-8 characters where byte offsets can land inside a character.
457+
for (end_idx, _) in s.char_indices().rev() {
458+
if end_idx <= start_idx {
459+
break;
460+
}
457461
let candidate = &s[start_idx..end_idx];
458462

459463
// Try to repair and parse this candidate
@@ -464,6 +468,15 @@ fn extract_array_from_string(s: &str) -> Option<Value> {
464468
}
465469
}
466470

471+
// Also try the full string as a last resort (end at s.len() which is
472+
// always a valid boundary)
473+
let candidate = &s[start_idx..];
474+
if let Ok(parsed) = crate::json_repair::<Value>(candidate)
475+
&& parsed.is_array()
476+
{
477+
return Some(parsed);
478+
}
479+
467480
None
468481
}
469482

@@ -1344,4 +1357,39 @@ mod tests {
13441357
let expected = json!({"count": null});
13451358
assert_eq!(actual, expected);
13461359
}
1360+
1361+
#[test]
1362+
fn test_extract_array_from_string_with_multibyte_chars() {
1363+
// Multi-byte UTF-8 characters (like arrows and emojis) should not
1364+
// cause panics when extract_array_from_string iterates over byte
1365+
// positions. The function must only slice at valid char boundaries.
1366+
let input = "prefix → [1, 2, 3] suffix";
1367+
let result = extract_array_from_string(input);
1368+
assert!(result.is_some());
1369+
let arr = result.unwrap();
1370+
assert!(arr.is_array());
1371+
assert_eq!(arr.as_array().unwrap().len(), 3);
1372+
}
1373+
1374+
#[test]
1375+
fn test_extract_array_from_string_with_emoji_prefix() {
1376+
// Emoji characters are 4 bytes each, many byte positions inside them
1377+
// are invalid char boundaries.
1378+
let input = "🔑🔒 [4, 5, 6]";
1379+
let result = extract_array_from_string(input);
1380+
assert!(result.is_some());
1381+
let arr = result.unwrap();
1382+
assert!(arr.is_array());
1383+
assert_eq!(arr.as_array().unwrap().len(), 3);
1384+
}
1385+
1386+
#[test]
1387+
fn test_extract_array_from_string_with_multibyte_inside_array() {
1388+
// Multi-byte chars inside the array value itself
1389+
let input = r#"["αβγ", "δεζ"]"#;
1390+
let result = extract_array_from_string(input);
1391+
assert!(result.is_some());
1392+
let arr = result.unwrap();
1393+
assert!(arr.is_array());
1394+
}
13471395
}

crates/forge_main/src/tools_display.rs

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,74 @@ pub fn format_tools(agent_tools: &[ToolName], overview: &ToolsOverview) -> Info
4848
for (server_name, error) in overview.mcp.get_failures().iter() {
4949
// Truncate error message for readability in list view
5050
// Use 'mcp show <name>' for full error details
51-
let truncated_error = if error.len() > 80 {
52-
format!("{}...", &error[..77])
53-
} else {
54-
error.clone()
55-
};
51+
let truncated_error = truncate_error(error);
5652
info = info.add_value(format!("[✗] {server_name} - {truncated_error}"));
5753
}
5854
}
5955

6056
info
6157
}
58+
59+
/// Truncates an error message to at most 80 characters for display.
60+
///
61+
/// If the message exceeds 80 characters, the first 77 characters are kept
62+
/// followed by "...". Uses character-based counting to avoid panicking on
63+
/// multi-byte UTF-8 strings.
64+
fn truncate_error(error: &str) -> String {
65+
if error.chars().count() > 80 {
66+
let truncated: String = error.chars().take(77).collect();
67+
format!("{truncated}...")
68+
} else {
69+
error.to_string()
70+
}
71+
}
72+
73+
#[cfg(test)]
74+
mod tests {
75+
use pretty_assertions::assert_eq;
76+
77+
use super::*;
78+
79+
#[test]
80+
fn test_truncate_error_short_message() {
81+
let fixture = "Connection refused";
82+
let actual = truncate_error(fixture);
83+
let expected = "Connection refused";
84+
assert_eq!(actual, expected);
85+
}
86+
87+
#[test]
88+
fn test_truncate_error_long_ascii_message() {
89+
let fixture = "A".repeat(100);
90+
let actual = truncate_error(&fixture);
91+
assert_eq!(actual.chars().count(), 80);
92+
assert!(actual.ends_with("..."));
93+
}
94+
95+
#[test]
96+
fn test_truncate_error_multibyte_chars_no_panic() {
97+
// Error containing multi-byte UTF-8 chars (→ is 3 bytes)
98+
let fixture = "Error: struct → prioritizes struct definitions, trait → prioritizes traits, impl → prioritizes impls, and more details follow here";
99+
let actual = truncate_error(fixture);
100+
// Should not panic and should truncate correctly by char count
101+
assert!(actual.chars().count() <= 80);
102+
assert!(actual.ends_with("..."));
103+
}
104+
105+
#[test]
106+
fn test_truncate_error_emoji_no_panic() {
107+
// Error containing 4-byte emojis - 90 emoji chars > 80 limit
108+
let fixture = "🚀".repeat(90);
109+
let actual = truncate_error(&fixture);
110+
assert_eq!(actual.chars().count(), 80);
111+
assert!(actual.ends_with("..."));
112+
}
113+
114+
#[test]
115+
fn test_truncate_error_exactly_80_chars() {
116+
let fixture = "A".repeat(80);
117+
let actual = truncate_error(&fixture);
118+
let expected = "A".repeat(80);
119+
assert_eq!(actual, expected);
120+
}
121+
}

crates/forge_repo/src/provider/provider_repo.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ impl<
460460
);
461461
tracing::debug!(
462462
"Token starts with: {}",
463-
&access_token.token[..access_token.token.len().min(20)]
463+
access_token.token.chars().take(20).collect::<String>()
464464
);
465465

466466
// Create new credential with fresh token, preserving url_params and provider ID

0 commit comments

Comments
 (0)