diff --git a/.gitignore b/.gitignore index a156ee9..ab1aa5e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ dist/ *.zip .cora/history/ .cora/index.db +.commit-msg.txt diff --git a/src/commands/commit_cmd.rs b/src/commands/commit_cmd.rs index b523011..1f8bbc9 100644 --- a/src/commands/commit_cmd.rs +++ b/src/commands/commit_cmd.rs @@ -241,10 +241,16 @@ async fn generate_commit_message( /// Build the user prompt for commit message generation. fn build_commit_prompt(diff: &str) -> String { - // Truncate very long diffs for commit message generation + // Truncate very long diffs for commit message generation. + // Use floor_char_boundary to avoid panicking on multi-byte UTF-8 + // (e.g. emoji or non-ASCII characters in code/comments). let max_chars = 8000; let truncated = if diff.len() > max_chars { - &diff[..max_chars] + let mut end = max_chars; + while !diff.is_char_boundary(end) { + end -= 1; + } + &diff[..end] } else { diff }; @@ -344,9 +350,13 @@ fn parse_commit_message(raw: &str) -> Result { format!("chore: {subject}") }; - // Enforce max length on subject + // Enforce max length on subject (char-boundary safe for UTF-8) let subject = if subject.len() > 72 { - format!("{}…", &subject[..69]) + let mut end = 69; + while !subject.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &subject[..end]) } else { subject }; @@ -532,6 +542,19 @@ mod tests { assert!(msg.subject.len() <= 75); // 72 + "…" } + #[test] + fn parse_truncates_long_subject_with_multibyte() { + // Subject with emoji near the truncation boundary (byte 69). + // Without char-boundary checking, &subject[..69] would split the + // 4-byte emoji at positions 67–70 and panic. + let mut long = "x".repeat(67); + long.push_str("πŸŽ‰rest_of_subject_here"); // emoji at bytes 67-70 + let raw = format!(r#"{{"subject":"{long}","body":""}}"#); + // Must not panic: + let msg = parse_commit_message(&raw).unwrap(); + assert!(msg.subject.contains('…')); + } + #[test] fn parse_invalid_json_fails() { let result = parse_commit_message("not json at all"); @@ -548,6 +571,23 @@ mod tests { assert!(prompt.contains("conventional commit")); } + #[test] + fn commit_prompt_truncates_multibyte_utf8_without_panic() { + // Fill 7998 ASCII bytes, then a 4-byte emoji, then more text. + // Without char-boundary checking, slicing at 8000 would land + // mid-codepoint and panic with "byte index is not a char boundary". + let mut diff = "a".repeat(7998); + diff.push('πŸŽ‰'); // 4 bytes: positions 7998..8002 + diff.push_str(&"b".repeat(200)); + + // This must not panic: + let prompt = build_commit_prompt(&diff); + assert!(prompt.contains("conventional commit")); + // Truncated output should not contain the partial emoji bytes + // (it ends before the emoji because the boundary floors to 7998). + assert!(!prompt.contains('πŸŽ‰')); + } + // ─── diff_stats ─── #[test] diff --git a/src/engine/secrets_scanner.rs b/src/engine/secrets_scanner.rs index 398ab55..03cd71a 100644 --- a/src/engine/secrets_scanner.rs +++ b/src/engine/secrets_scanner.rs @@ -178,9 +178,28 @@ pub fn scan_secrets(chunks: &[FileChunk], max_findings: usize) -> Vec String { if s.len() <= 12 { - return format!("{}****", &s[..s.len().min(4)]); + let end = floor_boundary(s, s.len().min(4)); + return format!("{}****", &s[..end]); } - format!("{}****{}", &s[..4], &s[s.len() - 4..]) + let head = floor_boundary(s, 4); + // For the tail, count back from the end until we have a valid boundary. + let tail_start = { + let mut idx = s.len() - 4; + while !s.is_char_boundary(idx) { + idx += 1; + } + idx + }; + format!("{}****{}", &s[..head], &s[tail_start..]) +} + +/// Find the largest byte index <= `target` that is a valid UTF-8 char boundary. +fn floor_boundary(s: &str, target: usize) -> usize { + let mut end = target.min(s.len()); + while !s.is_char_boundary(end) { + end -= 1; + } + end } #[cfg(test)] @@ -385,6 +404,25 @@ mod tests { assert_eq!(mask_secret("ghp_abcdef"), "ghp_****"); } + #[test] + fn mask_secret_multibyte_no_panic() { + // Secret containing multi-byte UTF-8 characters. + // Without char-boundary checking, &s[..4] could split a codepoint. + let secret = "πŸ”’secret-api-key-value-1234567890"; + // Should not panic and should still mask the middle. + let masked = mask_secret(secret); + assert!(masked.contains("****")); + } + + #[test] + fn mask_secret_multibyte_short_no_panic() { + // Short secret (≀12 bytes) with multi-byte chars. + // "πŸ”’ab" = 4 + 1 + 1 = 6 bytes, 3 chars. + let secret = "πŸ”’ab"; + let masked = mask_secret(secret); + assert!(masked.ends_with("****")); + } + #[test] fn no_secrets_clean_code() { let chunks = [make_chunk("main.py", &["x = 42", "print('hello')"])];