From e25818ee24661ba1a809274d5bf9e8d1ea76392b Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Wed, 24 Jun 2026 11:45:40 +0000 Subject: [PATCH] Fix Cursor hook stdin decoding for CJK content --- src/commands/git_ai_handlers.rs | 70 ++++++++++++++++++++++++++++++++- tests/integration/cursor.rs | 55 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 2264fcf8f6..7ef0401b9d 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -391,11 +391,18 @@ fn handle_checkpoint(args: &[String]) { hook_input = Some(strip_utf8_bom(args[i + 1].clone())); if hook_input.as_ref().unwrap() == "stdin" { let mut stdin = std::io::stdin(); - let mut buffer = String::new(); - if let Err(e) = stdin.read_to_string(&mut buffer) { + let mut buffer = Vec::new(); + if let Err(e) = stdin.read_to_end(&mut buffer) { eprintln!("Failed to read stdin for hook input: {}", e); std::process::exit(0); } + let buffer = match decode_hook_input_bytes(buffer) { + Ok(buffer) => buffer, + Err(e) => { + eprintln!("Failed to decode stdin for hook input: {}", e); + std::process::exit(0); + } + }; if buffer.trim().is_empty() { eprintln!("No hook input provided (via --hook-input or stdin)."); std::process::exit(0); @@ -574,6 +581,65 @@ fn strip_utf8_bom(input: String) -> String { } } +fn decode_hook_input_bytes(bytes: Vec) -> Result { + if bytes.starts_with(&[0xFF, 0xFE]) { + return decode_utf16_hook_input(&bytes[2..], Utf16Endian::Little); + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return decode_utf16_hook_input(&bytes[2..], Utf16Endian::Big); + } + + match likely_utf16_endian(&bytes) { + Some(endian) => decode_utf16_hook_input(&bytes, endian), + None => String::from_utf8(bytes).map_err(|e| e.to_string()), + } +} + +#[derive(Clone, Copy)] +enum Utf16Endian { + Little, + Big, +} + +fn likely_utf16_endian(bytes: &[u8]) -> Option { + let sample_len = bytes.len().min(512); + if sample_len < 8 { + return None; + } + + let sample = &bytes[..sample_len]; + let even_nuls = sample.iter().step_by(2).filter(|&&b| b == 0).count(); + let odd_nuls = sample + .iter() + .skip(1) + .step_by(2) + .filter(|&&b| b == 0) + .count(); + let min_nuls = sample_len / 8; + + if odd_nuls > min_nuls && odd_nuls > even_nuls.saturating_mul(4) { + Some(Utf16Endian::Little) + } else if even_nuls > min_nuls && even_nuls > odd_nuls.saturating_mul(4) { + Some(Utf16Endian::Big) + } else { + None + } +} + +fn decode_utf16_hook_input(bytes: &[u8], endian: Utf16Endian) -> Result { + let chunks = bytes.chunks_exact(2); + if !chunks.remainder().is_empty() { + return Err("UTF-16 hook input has an odd byte length".to_string()); + } + + let code_units = chunks.map(|chunk| match endian { + Utf16Endian::Little => u16::from_le_bytes([chunk[0], chunk[1]]), + Utf16Endian::Big => u16::from_be_bytes([chunk[0], chunk[1]]), + }); + + String::from_utf16(&code_units.collect::>()).map_err(|e| e.to_string()) +} + #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct EffectiveIgnorePatternsRequest { diff --git a/tests/integration/cursor.rs b/tests/integration/cursor.rs index 9f5d6e35af..5452f4b70b 100644 --- a/tests/integration/cursor.rs +++ b/tests/integration/cursor.rs @@ -245,6 +245,61 @@ fn test_cursor_checkpoint_stdin_with_utf8_bom() { ); } +fn utf16le_bytes(input: &str) -> Vec { + input + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect() +} + +#[test] +fn test_cursor_checkpoint_stdin_with_utf16le_odd_cjk_content() { + use std::fs; + + let repo = TestRepo::new(); + + let src_dir = repo.path().join("src"); + fs::create_dir_all(&src_dir).unwrap(); + + let file_path = repo.path().join("src/文案文.js"); + fs::write(&file_path, "const a = \"base\";\n").unwrap(); + repo.stage_all_and_commit("Initial commit").unwrap(); + let mut file = repo.filename("src/文案文.js"); + file.assert_committed_lines(crate::lines!["const a = \"base\";".unattributed_human(),]); + + let edited_content = "const a = \"文案文\"; // 3 chars\n"; + fs::write(&file_path, edited_content).unwrap(); + + let hook_input = serde_json::json!({ + "conversation_id": TEST_CONVERSATION_ID, + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "postToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": file_path.to_string_lossy().to_string(), + "content": edited_content, + }, + "model": "model-name-from-hook-test" + }) + .to_string(); + + let output = repo + .git_ai_with_stdin( + &["checkpoint", "cursor", "--hook-input", "stdin"], + &utf16le_bytes(&hook_input), + ) + .expect("checkpoint should parse UTF-16LE stdin payload with odd CJK content"); + + assert!( + !output.contains("Invalid JSON in hook_input"), + "Should not fail JSON parsing for odd CJK content in UTF-16LE stdin. Output: {output}" + ); + + repo.stage_all_and_commit("Add cursor CJK edit").unwrap(); + + file.assert_lines_and_blame(crate::lines!["const a = \"文案文\"; // 3 chars".ai(),]); +} + #[test] fn test_cursor_e2e_with_attribution() { use std::fs;