Skip to content

Commit da486bf

Browse files
committed
fix(tee): prevent panic on UTF-8 multi-byte truncation boundary
&raw[..max_file_size] panics if the byte offset falls inside a multi-byte UTF-8 character (e.g. Japanese, emoji). Now finds the nearest char boundary before slicing. Ref: issue #640 (L-3 finding) Signed-off-by: Patrick Szymkowiak <patrick@rtk-ai.app> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
1 parent aa6317f commit da486bf

1 file changed

Lines changed: 49 additions & 2 deletions

File tree

‎src/core/tee.rs‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,17 @@ fn write_tee_file(
121121
let filename = format!("{}_{}.log", epoch, slug);
122122
let filepath = tee_dir.join(filename);
123123

124-
// Truncate at max_file_size
124+
// Truncate at max_file_size (find a safe UTF-8 char boundary)
125125
let content = if raw.len() > max_file_size {
126+
let boundary = raw
127+
.char_indices()
128+
.take_while(|(i, _)| *i < max_file_size)
129+
.last()
130+
.map(|(i, c)| i + c.len_utf8())
131+
.unwrap_or(0);
126132
format!(
127133
"{}\n\n--- truncated at {} bytes ---",
128-
&raw[..max_file_size],
134+
&raw[..boundary],
129135
max_file_size
130136
)
131137
} else {
@@ -356,6 +362,47 @@ mod tests {
356362
assert!(content.len() < 2000);
357363
}
358364

365+
#[test]
366+
fn test_write_tee_file_truncation_utf8_boundary() {
367+
let tmpdir = tempfile::tempdir().unwrap();
368+
// Create a string where the truncation point falls inside a multi-byte char.
369+
// Japanese chars are 3 bytes each in UTF-8.
370+
// 332 chars * 3 bytes = 996 bytes, then one more = 999 bytes.
371+
// With max_file_size=998, the cut falls mid-character.
372+
let japanese = "\u{6F22}".repeat(333); // 999 bytes of 3-byte chars
373+
assert_eq!(japanese.len(), 999);
374+
375+
// Truncate at 998 — falls in the middle of the 333rd character
376+
let result = write_tee_file(&japanese, "test_utf8", tmpdir.path(), 998, 20);
377+
assert!(result.is_some());
378+
379+
let path = result.unwrap();
380+
let content = fs::read_to_string(&path).unwrap();
381+
assert!(content.contains("--- truncated at 998 bytes ---"));
382+
// Should contain 332 full characters (996 bytes), not panic
383+
assert!(content.starts_with(&"\u{6F22}".repeat(332)));
384+
}
385+
386+
#[test]
387+
fn test_write_tee_file_truncation_emoji() {
388+
let tmpdir = tempfile::tempdir().unwrap();
389+
// Emoji are 4 bytes each in UTF-8
390+
let emojis = "\u{1F600}".repeat(100); // 400 bytes
391+
assert_eq!(emojis.len(), 400);
392+
393+
// Truncate at 201 — falls mid-emoji (4-byte boundary is at 200, 204)
394+
let result = write_tee_file(&emojis, "test_emoji", tmpdir.path(), 201, 20);
395+
assert!(result.is_some());
396+
397+
let path = result.unwrap();
398+
let content = fs::read_to_string(&path).unwrap();
399+
assert!(content.contains("--- truncated at 201 bytes ---"));
400+
// The emoji portion should be exactly 200 bytes (50 emojis),
401+
// rounded down from 201 to the nearest char boundary
402+
let target = "\u{1F600}".repeat(50);
403+
assert!(content.starts_with(&target));
404+
}
405+
359406
#[test]
360407
fn test_cleanup_old_files() {
361408
let tmpdir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)