1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4use std::sync::mpsc;
5use std::time::Duration;
6
7const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
8mod observe;
9pub use observe::*;
10#[cfg(test)]
11mod tests;
12
13fn is_disabled() -> bool {
14 std::env::var("LEAN_CTX_DISABLED").is_ok()
15}
16
17fn is_harden_active() -> bool {
18 matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
19}
20
21fn is_shadow_mode_active() -> bool {
22 if matches!(std::env::var("LEAN_CTX_SHADOW"), Ok(v) if v.trim() == "1") {
23 return true;
24 }
25 crate::core::config::Config::load().shadow_mode
26}
27
28fn log_shadow_intercept(tool: &str, detail: &str) {
29 if !is_shadow_mode_active() {
30 return;
31 }
32 let Some(data_dir) = crate::core::data_dir::lean_ctx_data_dir().ok() else {
33 return;
34 };
35 let log_path = data_dir.join("shadow.log");
36 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
37 let line = format!("[{ts}] intercepted {tool}: {detail}\n");
38 let _ = std::fs::OpenOptions::new()
39 .create(true)
40 .append(true)
41 .open(log_path)
42 .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
43}
44
45fn is_quiet() -> bool {
46 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
47}
48
49pub fn mark_hook_environment() {
52 unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
55}
56
57pub fn arm_watchdog(timeout: Duration) {
62 std::thread::spawn(move || {
63 std::thread::sleep(timeout);
64 eprintln!(
65 "[lean-ctx hook] watchdog timeout after {}s — force exit",
66 timeout.as_secs()
67 );
68 std::process::exit(1);
69 });
70}
71
72fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
74 let (tx, rx) = mpsc::channel();
75 std::thread::spawn(move || {
76 let mut buf = String::new();
77 let result = std::io::stdin().read_to_string(&mut buf);
78 let _ = tx.send(result.ok().map(|_| buf));
79 });
80 match rx.recv_timeout(timeout) {
81 Ok(Some(s)) if !s.is_empty() => Some(s),
82 _ => None,
83 }
84}
85
86fn build_dual_allow_output() -> String {
87 serde_json::json!({
88 "permission": "allow",
89 "hookSpecificOutput": {
90 "hookEventName": "PreToolUse",
91 "permissionDecision": "allow"
92 }
93 })
94 .to_string()
95}
96
97fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
98 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
99 let mut m = obj.clone();
100 m.insert(
101 "command".to_string(),
102 serde_json::Value::String(rewritten.to_string()),
103 );
104 serde_json::Value::Object(m)
105 } else {
106 serde_json::json!({ "command": rewritten })
107 };
108
109 serde_json::json!({
110 "permission": "allow",
112 "updated_input": updated_input,
113 "hookSpecificOutput": {
115 "hookEventName": "PreToolUse",
116 "permissionDecision": "allow",
117 "updatedInput": {
118 "command": rewritten
119 }
120 }
121 })
122 .to_string()
123}
124
125pub fn handle_rewrite() {
126 let allow = build_dual_allow_output();
127 if is_disabled() {
128 print!("{allow}");
129 return;
130 }
131 let binary = resolve_binary();
132 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
133 print!("{allow}");
134 return;
135 };
136
137 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
138 tracing::warn!("[hook rewrite] invalid JSON payload, allowing passthrough");
139 print!("{allow}");
140 return;
141 };
142
143 let tool = v.get("tool_name").and_then(|t| t.as_str());
144 let Some(tool_name) = tool else {
145 print!("{allow}");
146 return;
147 };
148
149 let is_shell_tool = matches!(
150 tool_name,
151 "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
152 );
153 if !is_shell_tool {
154 print!("{allow}");
155 return;
156 }
157
158 let tool_input = v.get("tool_input");
159 let Some(cmd) = tool_input
160 .and_then(|ti| ti.get("command"))
161 .and_then(|c| c.as_str())
162 .or_else(|| v.get("command").and_then(|c| c.as_str()))
163 else {
164 print!("{allow}");
165 return;
166 };
167
168 if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
169 print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
170 } else {
171 print!("{allow}");
172 }
173}
174
175fn is_rewritable(cmd: &str) -> bool {
176 rewrite_registry::is_rewritable_command(cmd)
177}
178
179fn wrap_single_command(cmd: &str, binary: &str) -> String {
180 if cfg!(windows) {
181 let escaped = cmd.replace('"', "\\\"");
182 format!("{binary} -c \"{escaped}\"")
183 } else {
184 let shell_escaped = cmd.replace('\'', "'\\''");
185 format!("{binary} -c '{shell_escaped}'")
186 }
187}
188
189fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
190 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
191 return None;
192 }
193
194 if cmd.contains("<<") {
197 return None;
198 }
199
200 if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
201 return Some(rewritten);
202 }
203
204 if let Some(rewritten) = rewrite_search_command(cmd, binary) {
205 return Some(rewritten);
206 }
207
208 if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
209 return Some(rewritten);
210 }
211
212 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
213 return Some(rewritten);
214 }
215
216 if is_rewritable(cmd) {
217 return Some(wrap_single_command(cmd, binary));
218 }
219
220 None
221}
222
223fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
226 if !rewrite_registry::is_file_read_command(cmd) {
227 return None;
228 }
229
230 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
232 return None;
233 }
234
235 if cmd.contains(">&") || cmd.contains(">>") || cmd.contains(" >") {
237 return None;
238 }
239
240 let parts = shell_tokenize(cmd);
241 if parts.len() < 2 {
242 return None;
243 }
244
245 match parts[0].as_str() {
246 "cat" => {
247 let path = parts[1..].join(" ");
248 if is_outside_project_path(&path) {
249 return None;
250 }
251 Some(format!("{binary} read {}", shell_quote(&path)))
252 }
253 "head" => {
254 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
255 let (n, path) = parse_head_tail_args(&refs);
256 let path = path?;
257 if is_outside_project_path(path) {
258 return None;
259 }
260 let qp = shell_quote(path);
261 match n {
262 Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
263 None => Some(format!("{binary} read {qp} -m lines:1-10")),
264 }
265 }
266 "tail" => {
267 let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
268 let (n, path) = parse_head_tail_args(&refs);
269 let path = path?;
270 if is_outside_project_path(path) {
271 return None;
272 }
273 let qp = shell_quote(path);
274 let lines = n.unwrap_or(10);
275 Some(format!("{binary} read {qp} -m lines:-{lines}"))
276 }
277 _ => None,
278 }
279}
280
281fn is_outside_project_path(path: &str) -> bool {
285 let trimmed = path.trim();
286
287 if trimmed.starts_with('~') {
289 return true;
290 }
291
292 if trimmed.starts_with('$') {
294 return true;
295 }
296
297 if trimmed.starts_with("/proc/")
299 || trimmed.starts_with("/sys/")
300 || trimmed.starts_with("/dev/")
301 || trimmed.starts_with("/tmp/")
302 || trimmed.starts_with("/var/")
303 {
304 return true;
305 }
306
307 if trimmed.starts_with('/') {
311 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
313 return true;
314 }
315 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
317 return true;
318 }
319 }
320
321 false
322}
323
324fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
326 let parts = shell_tokenize(cmd);
327 if parts.first().map(String::as_str) != Some("rg") {
328 return None;
329 }
330 if parts.len() < 2 || parts.len() > 3 {
331 return None;
332 }
333 if parts[1].starts_with('-') {
334 return None;
335 }
336 let pattern = &parts[1];
337 match parts.get(2) {
338 Some(p) if p.starts_with('-') => None,
339 Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
340 None => Some(format!("{binary} grep {pattern}")),
341 }
342}
343
344fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
346 let parts = shell_tokenize(cmd);
347 if parts.first().map(String::as_str) != Some("ls") {
348 return None;
349 }
350 match parts.len() {
351 1 => Some(format!("{binary} ls")),
352 2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", shell_quote(&parts[1]))),
353 _ => None,
354 }
355}
356
357pub fn shell_tokenize(input: &str) -> Vec<String> {
359 let mut tokens = Vec::new();
360 let mut current = String::new();
361 let mut chars = input.chars().peekable();
362 let mut in_single = false;
363 let mut in_double = false;
364
365 while let Some(c) = chars.next() {
366 match c {
367 '\'' if !in_double => in_single = !in_single,
368 '"' if !in_single => in_double = !in_double,
369 '\\' if !in_single => {
370 if let Some(next) = chars.next() {
371 current.push(next);
372 }
373 }
374 c if c.is_whitespace() && !in_single && !in_double => {
375 if !current.is_empty() {
376 tokens.push(std::mem::take(&mut current));
377 }
378 }
379 _ => current.push(c),
380 }
381 }
382 if !current.is_empty() {
383 tokens.push(current);
384 }
385 tokens
386}
387
388pub fn shell_quote(s: &str) -> String {
390 if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
391 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
392 } else {
393 s.to_string()
394 }
395}
396
397fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
398 let mut n: Option<usize> = None;
399 let mut path: Option<&str> = None;
400
401 let mut i = 0;
402 while i < args.len() {
403 if args[i] == "-n" && i + 1 < args.len() {
404 n = args[i + 1].parse().ok();
405 i += 2;
406 } else if let Some(num) = args[i].strip_prefix("-n") {
407 n = num.parse().ok();
408 i += 1;
409 } else if args[i].starts_with('-') && args[i].len() > 1 {
410 if let Ok(num) = args[i][1..].parse::<usize>() {
411 n = Some(num);
412 }
413 i += 1;
414 } else {
415 path = Some(args[i]);
416 i += 1;
417 }
418 }
419
420 (n, path)
421}
422
423fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
424 compound_lexer::rewrite_compound(cmd, |segment| {
425 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
426 return None;
427 }
428 if is_rewritable(segment) {
429 Some(wrap_single_command(segment, binary))
430 } else {
431 None
432 }
433 })
434}
435
436fn emit_rewrite(rewritten: &str) {
437 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
438 print!(
439 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
440 );
441}
442
443pub fn handle_redirect() {
444 let allow = build_dual_allow_output();
445 if is_disabled() {
446 let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
447 print!("{allow}");
448 return;
449 }
450
451 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
452 print!("{allow}");
453 return;
454 };
455
456 let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
457 tracing::warn!("[hook redirect] invalid JSON payload, allowing passthrough");
458 print!("{allow}");
459 return;
460 };
461
462 let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
463 let tool_input = v.get("tool_input");
464
465 match tool_name {
466 "Read" | "read" | "read_file" => redirect_read(tool_input),
467 "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
468 _ => print!("{allow}"),
469 }
470}
471
472fn redirect_read(tool_input: Option<&serde_json::Value>) {
476 let path = tool_input
477 .and_then(|ti| ti.get("path"))
478 .and_then(|p| p.as_str())
479 .unwrap_or("");
480
481 if path.is_empty() || should_passthrough(path) {
482 print!("{}", build_dual_allow_output());
483 return;
484 }
485
486 let shadow = is_shadow_mode_active();
487 if is_harden_active() || shadow {
488 tracing::info!(
489 "[hook redirect] {} active, redirecting Read through lean-ctx",
490 if shadow { "shadow mode" } else { "harden mode" }
491 );
492 }
493
494 let binary = resolve_binary();
495 let temp_path = redirect_temp_path(path);
496
497 if let Some(mut output) =
498 run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT)
499 {
500 if shadow {
501 let header = format!(
502 "[shadow-mode: Read intercepted → ctx_read(\"{path}\", \"full\"). Use ctx_read directly for better performance.]\n\n"
503 );
504 let mut prefixed = header.into_bytes();
505 prefixed.append(&mut output);
506 output = prefixed;
507 }
508 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
509 let temp_str = temp_path.to_str().unwrap_or("");
510 print!("{}", build_redirect_output(tool_input, "path", temp_str));
511 log_shadow_intercept("Read", path);
512 return;
513 }
514 }
515
516 print!("{}", build_dual_allow_output());
517}
518
519fn redirect_grep(tool_input: Option<&serde_json::Value>) {
521 let pattern = tool_input
522 .and_then(|ti| ti.get("pattern"))
523 .and_then(|p| p.as_str())
524 .unwrap_or("");
525 let search_path = tool_input
526 .and_then(|ti| ti.get("path"))
527 .and_then(|p| p.as_str())
528 .unwrap_or(".");
529
530 if pattern.is_empty() {
531 print!("{}", build_dual_allow_output());
532 return;
533 }
534
535 let shadow = is_shadow_mode_active();
536 if is_harden_active() || shadow {
537 tracing::info!(
538 "[hook redirect] {} active, redirecting Grep through lean-ctx",
539 if shadow { "shadow mode" } else { "harden mode" }
540 );
541 }
542
543 let binary = resolve_binary();
544 let key = format!("grep:{pattern}:{search_path}");
545 let temp_path = redirect_temp_path(&key);
546
547 if let Some(mut output) = run_with_timeout(
548 &binary,
549 &["grep", pattern, search_path],
550 REDIRECT_SUBPROCESS_TIMEOUT,
551 ) {
552 if shadow {
553 let header = format!(
554 "[shadow-mode: Grep intercepted → ctx_search(\"{pattern}\", \"{search_path}\"). Use ctx_search directly for better performance.]\n\n"
555 );
556 let mut prefixed = header.into_bytes();
557 prefixed.append(&mut output);
558 output = prefixed;
559 }
560 if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
561 let temp_str = temp_path.to_str().unwrap_or("");
562 print!("{}", build_redirect_output(tool_input, "path", temp_str));
563 log_shadow_intercept("Grep", &format!("{pattern} in {search_path}"));
564 return;
565 }
566 }
567
568 print!("{}", build_dual_allow_output());
569}
570
571const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
572
573fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
576 let mut child = std::process::Command::new(binary)
577 .args(args)
578 .stdout(std::process::Stdio::piped())
579 .stderr(std::process::Stdio::null())
580 .spawn()
581 .ok()?;
582
583 let deadline = std::time::Instant::now() + timeout;
584 loop {
585 match child.try_wait() {
586 Ok(Some(status)) if status.success() => {
587 let mut stdout = Vec::new();
588 if let Some(mut out) = child.stdout.take() {
589 let _ = out.read_to_end(&mut stdout);
590 }
591 return if stdout.is_empty() {
592 None
593 } else {
594 Some(stdout)
595 };
596 }
597 Ok(Some(_)) | Err(_) => return None,
598 Ok(None) => {
599 if std::time::Instant::now() > deadline {
600 let _ = child.kill();
601 let _ = child.wait();
602 return None;
603 }
604 std::thread::sleep(Duration::from_millis(10));
605 }
606 }
607 }
608}
609
610fn redirect_temp_path(key: &str) -> std::path::PathBuf {
611 use std::collections::hash_map::DefaultHasher;
612 use std::hash::{Hash, Hasher};
613
614 let mut hasher = DefaultHasher::new();
615 key.hash(&mut hasher);
616 std::process::id().hash(&mut hasher);
617 let hash = hasher.finish();
618
619 let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
620 let _ = std::fs::create_dir_all(&temp_dir);
621 #[cfg(unix)]
622 {
623 use std::os::unix::fs::PermissionsExt;
624 let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
625 }
626 temp_dir.join(format!("{hash:016x}.lctx"))
627}
628
629fn build_redirect_output(
630 tool_input: Option<&serde_json::Value>,
631 field: &str,
632 temp_path: &str,
633) -> String {
634 let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
635 let mut m = obj.clone();
636 m.insert(
637 field.to_string(),
638 serde_json::Value::String(temp_path.to_string()),
639 );
640 serde_json::Value::Object(m)
641 } else {
642 serde_json::json!({ field: temp_path })
643 };
644
645 serde_json::json!({
646 "permission": "allow",
647 "updated_input": updated_input,
648 "hookSpecificOutput": {
649 "hookEventName": "PreToolUse",
650 "permissionDecision": "allow",
651 "updatedInput": { field: temp_path }
652 }
653 })
654 .to_string()
655}
656
657const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
658 ".cursorrules",
659 ".cursor/rules",
660 ".cursor/hooks",
661 "skill.md",
662 "agents.md",
663 ".env",
664 "hooks.json",
665 "node_modules",
666];
667
668const PASSTHROUGH_EXTENSIONS: &[&str] = &[
669 "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
670];
671
672fn should_passthrough(path: &str) -> bool {
673 let p = path.to_lowercase();
674
675 if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
676 return true;
677 }
678
679 std::path::Path::new(&p)
680 .extension()
681 .and_then(|ext| ext.to_str())
682 .is_some_and(|ext| {
683 PASSTHROUGH_EXTENSIONS
684 .iter()
685 .any(|e| ext.eq_ignore_ascii_case(e))
686 })
687}
688
689fn codex_rewrite_output(rewritten: &str) -> String {
690 serde_json::json!({
691 "hookSpecificOutput": {
692 "hookEventName": "PreToolUse",
693 "permissionDecision": "allow",
694 "updatedInput": {
695 "command": rewritten
696 }
697 }
698 })
699 .to_string()
700}
701
702pub fn handle_codex_pretooluse() {
703 if is_disabled() {
704 return;
705 }
706 let binary = resolve_binary();
707 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
708 return;
709 };
710
711 let tool = extract_json_field(&input, "tool_name");
712 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
713 return;
714 }
715
716 let Some(cmd) = extract_json_field(&input, "command") else {
717 return;
718 };
719
720 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
721 print!("{}", codex_rewrite_output(&rewritten));
722 }
723}
724
725pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
735 serde_json::json!({
736 "hookSpecificOutput": {
737 "hookEventName": "SessionStart",
738 "additionalContext": additional_context,
739 }
740 })
741 .to_string()
742}
743
744pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
745 println!(
746 "{}",
747 session_start_additional_context_json(additional_context)
748 );
749}
750
751pub fn handle_codex_session_start() {
752 if is_quiet() {
753 return;
754 }
755 if crate::core::config::Config::load().dedicated_session_context_active() {
759 return;
760 }
761 emit_session_start_additional_context(
762 "For shell commands matched by lean-ctx compression rules, prefer `lean-ctx -c \"<command>\"`. If a Bash call is blocked, rerun it with the exact command suggested by the hook.",
763 );
764}
765
766pub fn handle_copilot() {
770 if is_disabled() {
771 return;
772 }
773 let binary = resolve_binary();
774 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
775 return;
776 };
777
778 let tool = extract_json_field(&input, "tool_name");
779 let Some(tool_name) = tool.as_deref() else {
780 return;
781 };
782
783 let is_shell_tool = matches!(
784 tool_name,
785 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
786 );
787 if !is_shell_tool {
788 return;
789 }
790
791 let Some(cmd) = extract_json_field(&input, "command") else {
792 return;
793 };
794
795 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
796 emit_rewrite(&rewritten);
797 }
798}
799
800pub fn handle_rewrite_inline() {
805 if is_disabled() {
806 return;
807 }
808 let binary = resolve_binary_native();
809 let args: Vec<String> = std::env::args().collect();
810 if args.len() < 4 {
812 return;
813 }
814 let cmd = args[3..].join(" ");
815
816 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
817 print!("{rewritten}");
818 return;
819 }
820
821 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
822 print!("{cmd}");
823 return;
824 }
825
826 print!("{cmd}");
827}
828
829fn resolve_binary() -> String {
830 let path = crate::core::portable_binary::resolve_portable_binary();
831 crate::hooks::to_bash_compatible_path(&path)
832}
833
834fn resolve_binary_native() -> String {
835 crate::core::portable_binary::resolve_portable_binary()
836}
837
838fn extract_json_field(input: &str, field: &str) -> Option<String> {
839 let key = format!("\"{field}\":");
840 let key_pos = input.find(&key)?;
841 let after_colon = &input[key_pos + key.len()..];
842 let trimmed = after_colon.trim_start();
843 if !trimmed.starts_with('"') {
844 return None;
845 }
846 let rest = &trimmed[1..];
847 let bytes = rest.as_bytes();
848 let mut end = 0;
849 while end < bytes.len() {
850 if bytes[end] == b'\\' && end + 1 < bytes.len() {
851 end += 2;
852 continue;
853 }
854 if bytes[end] == b'"' {
855 break;
856 }
857 end += 1;
858 }
859 if end >= bytes.len() {
860 return None;
861 }
862 let raw = &rest[..end];
863 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
864}