Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ dist/
*.zip
.cora/history/
.cora/index.db
.commit-msg.txt
48 changes: 44 additions & 4 deletions src/commands/commit_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -344,9 +350,13 @@ fn parse_commit_message(raw: &str) -> Result<CommitMessage> {
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
};
Expand Down Expand Up @@ -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");
Expand 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]
Expand Down
42 changes: 40 additions & 2 deletions src/engine/secrets_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,28 @@ pub fn scan_secrets(chunks: &[FileChunk], max_findings: usize) -> Vec<RuleFindin
/// Mask a secret value: show first 4 and last 4 chars, replace middle with ****.
fn mask_secret(s: &str) -> 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)]
Expand Down Expand Up @@ -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')"])];
Expand Down
Loading