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