Skip to content

Commit aff3bf4

Browse files
committed
fix(cli): use safe UTF-8 slicing for session IDs in lock command
1 parent d201070 commit aff3bf4

File tree

1 file changed

+48
-4
lines changed

1 file changed

+48
-4
lines changed

src/cortex-cli/src/lock_cmd.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ fn validate_session_id(session_id: &str) -> Result<()> {
114114
)
115115
}
116116

117+
/// Safely get a string prefix by character count, not byte count.
118+
/// This avoids panics on multi-byte UTF-8 characters.
119+
fn safe_char_prefix(s: &str, max_chars: usize) -> &str {
120+
match s.char_indices().nth(max_chars) {
121+
Some((byte_idx, _)) => &s[..byte_idx],
122+
None => s, // String has fewer than max_chars characters
123+
}
124+
}
125+
117126
/// Get the lock file path.
118127
fn get_lock_file_path() -> PathBuf {
119128
dirs::home_dir()
@@ -156,7 +165,7 @@ pub fn is_session_locked(session_id: &str) -> bool {
156165
match load_lock_file() {
157166
Ok(lock_file) => lock_file.locked_sessions.iter().any(|entry| {
158167
entry.session_id == session_id
159-
|| session_id.starts_with(&entry.session_id[..8.min(entry.session_id.len())])
168+
|| session_id.starts_with(safe_char_prefix(&entry.session_id, 8))
160169
}),
161170
Err(_) => false,
162171
}
@@ -308,7 +317,7 @@ async fn run_list(args: LockListArgs) -> Result<()> {
308317
println!("{}", "-".repeat(60));
309318

310319
for entry in &lock_file.locked_sessions {
311-
let short_id = &entry.session_id[..8.min(entry.session_id.len())];
320+
let short_id = safe_char_prefix(&entry.session_id, 8);
312321
println!(" {} - locked at {}", short_id, entry.locked_at);
313322
if let Some(ref reason) = entry.reason {
314323
println!(" Reason: {}", reason);
@@ -332,7 +341,7 @@ async fn run_check(args: LockCheckArgs) -> Result<()> {
332341
e.session_id == args.session_id
333342
|| args
334343
.session_id
335-
.starts_with(&e.session_id[..8.min(e.session_id.len())])
344+
.starts_with(safe_char_prefix(&e.session_id, 8))
336345
});
337346

338347
if is_locked {
@@ -342,7 +351,7 @@ async fn run_check(args: LockCheckArgs) -> Result<()> {
342351
e.session_id == args.session_id
343352
|| args
344353
.session_id
345-
.starts_with(&e.session_id[..8.min(e.session_id.len())])
354+
.starts_with(safe_char_prefix(&e.session_id, 8))
346355
}) && let Some(ref reason) = entry.reason
347356
{
348357
println!("Reason: {}", reason);
@@ -508,4 +517,39 @@ mod tests {
508517
let path_str = path.to_string_lossy();
509518
assert!(path_str.contains(".cortex"));
510519
}
520+
521+
#[test]
522+
fn test_safe_char_prefix_ascii() {
523+
// ASCII strings should work correctly
524+
assert_eq!(safe_char_prefix("abcdefghij", 8), "abcdefgh");
525+
assert_eq!(safe_char_prefix("abc", 8), "abc");
526+
assert_eq!(safe_char_prefix("", 8), "");
527+
assert_eq!(safe_char_prefix("12345678", 8), "12345678");
528+
}
529+
530+
#[test]
531+
fn test_safe_char_prefix_utf8_multibyte() {
532+
// Multi-byte UTF-8 characters should not panic
533+
// Each emoji is 4 bytes, so 8 chars = 32 bytes
534+
let emoji_id = "🔥🎉🚀💡🌟✨🎯🔮extra";
535+
assert_eq!(safe_char_prefix(emoji_id, 8), "🔥🎉🚀💡🌟✨🎯🔮");
536+
537+
// Mixed ASCII and multi-byte
538+
let mixed = "ab🔥cd🎉ef";
539+
assert_eq!(safe_char_prefix(mixed, 4), "ab🔥c");
540+
assert_eq!(safe_char_prefix(mixed, 8), "ab🔥cd🎉ef");
541+
542+
// Chinese characters (3 bytes each)
543+
let chinese = "中文测试会话标识符";
544+
assert_eq!(safe_char_prefix(chinese, 4), "中文测试");
545+
}
546+
547+
#[test]
548+
fn test_safe_char_prefix_boundary() {
549+
// Edge cases
550+
assert_eq!(safe_char_prefix("a", 0), "");
551+
assert_eq!(safe_char_prefix("a", 1), "a");
552+
assert_eq!(safe_char_prefix("🔥", 1), "🔥");
553+
assert_eq!(safe_char_prefix("🔥", 0), "");
554+
}
511555
}

0 commit comments

Comments
 (0)