1use crate::cli::DoctorArgs;
2use crate::error::{AppError, AppResult, unsupported_log_version_message};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{LogEvent, compute_dogear_id, compute_id, compute_promotion_id, normalized};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::fs::{self, File, OpenOptions};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13const EMPTY_WARNING: &str = "no blotter file yet; healthy empty state";
14const EMPTY_FIX: &str = "Pass an existing --file PATH or omit --file to inspect discovered state.";
15const EVIDENCE_DELIMITERS: &[u8] = b",;)]}&#\"'";
18const HOME_PREFIXES: [&[u8]; 4] = [b"/Users/", b"/home/", b"-Users-", b"-home-"];
20
21struct LeakScan<'a> {
22 home: Option<Vec<u8>>,
23 dash_home: Option<Vec<u8>>,
24 deny: &'a [String],
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct DoctorData {
29 pub healthy: bool,
30 pub findings: Vec<Finding>,
31 pub checked_lines: usize,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub fix: Option<FixData>,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37pub struct Finding {
38 pub line: usize,
39 pub kind: String,
40 pub message: String,
41 #[serde(default)]
42 pub fixable: bool,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
46pub struct FixData {
47 pub changed: bool,
48 pub applied: Vec<AppliedFix>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub backup: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub quarantine: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub restore_hint: Option<String>,
55 pub dry_run: bool,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59pub struct AppliedFix {
60 pub line: usize,
61 pub kind: String,
62 pub action: String,
63}
64
65pub fn run(
66 args: DoctorArgs,
67 file: Option<PathBuf>,
68 pretty: bool,
69 now: Timestamp,
70) -> AppResult<i32> {
71 if args.dry_run && !args.fix {
72 return Err(AppError::invalid_argument(
73 "--dry-run requires --fix for doctor",
74 "Run `blotter doctor --fix --dry-run` to preview repairs.",
75 ));
76 }
77 if args.leaks && args.fix {
78 return Err(AppError::invalid_argument(
79 "--leaks conflicts with --fix for doctor",
80 "Run `blotter doctor --leaks` without --fix; the gate is read-only.",
81 ));
82 }
83 if !args.deny.is_empty() && !args.leaks {
84 return Err(AppError::invalid_argument(
85 "--deny requires --leaks for doctor",
86 "Run `blotter doctor --leaks --deny LITERAL` to scan a literal deny pattern.",
87 ));
88 }
89 if args.deny.iter().any(|pattern| pattern.is_empty()) {
90 return Err(AppError::invalid_argument(
91 "--deny requires a non-empty literal",
92 "Run `blotter doctor --leaks --deny LITERAL` with a non-empty literal.",
93 ));
94 }
95 let leak_scan = args.leaks.then(|| {
96 let home = current_home_path();
97 let dash_home = home
100 .as_ref()
101 .filter(|home| home.as_slice() != b"/")
102 .map(|home| {
103 home.iter()
104 .map(|byte| if *byte == b'/' { b'-' } else { *byte })
105 .collect()
106 });
107 LeakScan {
108 home,
109 dash_home,
110 deny: &args.deny,
111 }
112 });
113 let resolved = store::discover(file)?;
114 let mut warnings = resolved.warnings.clone();
115 let (mut data, file_existed) = match (args.fix, args.dry_run) {
116 (false, _) => diagnose_shared(&resolved, &mut warnings, leak_scan.as_ref())?,
117 (true, true) => {
118 let (mut data, file_existed) =
119 diagnose_shared(&resolved, &mut warnings, leak_scan.as_ref())?;
120 data.fix = Some(FixData {
121 changed: false,
122 applied: planned_fixes(&data.findings),
123 backup: None,
124 quarantine: None,
125 restore_hint: None,
126 dry_run: true,
127 });
128 (data, file_existed)
129 }
130 (true, false) => diagnose_and_fix(&resolved, &mut warnings, now)?,
131 };
132 add_gitignored_finding(&mut data, &resolved, file_existed);
133 let exit = i32::from(!data.healthy);
134 let mut meta = Meta::new();
135 meta.file = Some(resolved.path.to_string_lossy().into_owned());
136 meta.warnings = warnings;
137 output::write_success(data, pretty, meta)
138 .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
139 Ok(exit)
140}
141
142fn diagnose_shared(
143 resolved: &store::ResolvedFile,
144 warnings: &mut Vec<String>,
145 leak_scan: Option<&LeakScan<'_>>,
146) -> AppResult<(DoctorData, bool)> {
147 store::read_or_empty(
148 &resolved.path,
149 resolved.explicit,
150 warnings,
151 EMPTY_WARNING,
152 EMPTY_FIX,
153 empty_data,
154 |log| {
155 let bytes = store::read_bytes(log, &resolved.path)?;
156 Ok(inspect(&bytes, leak_scan))
157 },
158 )
159}
160
161fn diagnose_and_fix(
162 resolved: &store::ResolvedFile,
163 warnings: &mut Vec<String>,
164 now: Timestamp,
165) -> AppResult<(DoctorData, bool)> {
166 match store::with_exclusive(&resolved.path, false, |log| {
167 apply_fixes(log, &resolved.path, now)
168 }) {
169 Ok(data) => Ok((data, true)),
170 Err(error) if error.code == "not_found" && error.exit_code == 66 && !resolved.explicit => {
171 warnings.push(EMPTY_WARNING.into());
172 let mut data = empty_data();
173 data.fix = Some(FixData {
174 changed: false,
175 applied: Vec::new(),
176 backup: None,
177 quarantine: None,
178 restore_hint: None,
179 dry_run: false,
180 });
181 Ok((data, false))
182 }
183 Err(error) if error.code == "not_found" && error.exit_code == 66 => {
184 Err(AppError::not_found(
185 format!("blotter file not found: {}", resolved.path.display()),
186 EMPTY_FIX,
187 ))
188 }
189 Err(error) => Err(error),
190 }
191}
192
193fn apply_fixes(log: &mut File, path: &Path, now: Timestamp) -> AppResult<DoctorData> {
194 let original = store::read_bytes(log, path)?;
195 let before = inspect(&original, None);
196 let applied = planned_fixes(&before.findings);
197 if applied.is_empty() {
198 return Ok(with_fix(
199 before,
200 FixData {
201 changed: false,
202 applied,
203 backup: None,
204 quarantine: None,
205 restore_hint: None,
206 dry_run: false,
207 },
208 ));
209 }
210
211 let permissions = log
212 .metadata()
213 .map_err(|error| AppError::from_io(error, path))?
214 .permissions();
215 let path = &store::resolve_symlinked_log(path)?;
218 let backup_path = store::suffixed_path(path, &format!(".bak-{}", store::backup_timestamp(now)));
219 if backup_path.exists() {
220 return Err(AppError::stale_backup(&backup_path));
221 }
222 let backup = store::write_new_file(&backup_path, &original, &permissions)?;
223 let quarantine_path = store::suffixed_path(path, ".quarantine.jsonl");
224 let quarantine_len = fs::metadata(&quarantine_path)
227 .ok()
228 .map(|metadata| metadata.len());
229 let quarantined = quarantined_bytes(&original, &applied);
230 let quarantine = match store::append_file(&quarantine_path, &quarantined, &permissions) {
231 Ok(quarantine) => quarantine,
232 Err(error) => {
233 undo_created_outputs(&[
236 (backup.as_path(), None),
237 (quarantine_path.as_path(), quarantine_len),
238 ]);
239 return Err(error);
240 }
241 };
242 let repaired = repaired_bytes(&original, &applied);
243 if let Err(error) = store::replace_log(
244 path,
245 &repaired,
246 &permissions,
247 &format!(".tmp-fix-{}", std::process::id()),
248 ) {
249 undo_created_outputs(&[
250 (backup.as_path(), None),
251 (quarantine.as_path(), quarantine_len),
252 ]);
253 return Err(error);
254 }
255 Ok(with_fix(
260 post_fix_data(&before, &applied),
261 FixData {
262 changed: true,
263 applied,
264 backup: Some(backup.to_string_lossy().into_owned()),
265 quarantine: Some(quarantine.to_string_lossy().into_owned()),
266 restore_hint: Some(store::restore_hint(&backup, path)),
267 dry_run: false,
268 },
269 ))
270}
271
272fn undo_created_outputs(outputs: &[(&Path, Option<u64>)]) {
277 for (path, previous_len) in outputs {
278 match previous_len {
279 None => {
280 let _ = fs::remove_file(path);
281 }
282 Some(len) => {
283 let _ = OpenOptions::new()
284 .write(true)
285 .open(path)
286 .and_then(|file| file.set_len(*len));
287 }
288 }
289 }
290}
291
292fn post_fix_data(before: &DoctorData, applied: &[AppliedFix]) -> DoctorData {
303 let mut removed: Vec<usize> = applied
304 .iter()
305 .filter(|fix| fix.action == "quarantined")
306 .map(|fix| fix.line)
307 .collect();
308 removed.sort_unstable();
309 let findings: Vec<Finding> = before
310 .findings
311 .iter()
312 .filter(|finding| removed.binary_search(&finding.line).is_err())
313 .map(|finding| Finding {
314 line: finding.line - removed.partition_point(|line| *line < finding.line),
315 kind: finding.kind.clone(),
316 message: finding.message.clone(),
317 fixable: finding.fixable,
318 })
319 .collect();
320 DoctorData {
321 healthy: findings.is_empty(),
322 findings,
323 checked_lines: before.checked_lines - removed.len(),
324 fix: None,
325 }
326}
327
328fn with_fix(mut data: DoctorData, fix: FixData) -> DoctorData {
329 data.fix = Some(fix);
330 data
331}
332
333fn repaired_bytes(bytes: &[u8], applied: &[AppliedFix]) -> Vec<u8> {
334 let remove_lines: HashSet<_> = applied
335 .iter()
336 .filter(|fix| fix.action == "quarantined")
337 .map(|fix| fix.line)
338 .collect();
339 let mut repaired = Vec::new();
340 for (index, raw) in bytes.split_inclusive(|byte| *byte == b'\n').enumerate() {
341 if !remove_lines.contains(&(index + 1)) {
342 repaired.extend_from_slice(raw);
343 }
344 }
345 repaired
346}
347
348fn quarantined_bytes(bytes: &[u8], applied: &[AppliedFix]) -> Vec<u8> {
349 let remove_lines: HashSet<_> = applied
350 .iter()
351 .filter(|fix| fix.action == "quarantined")
352 .map(|fix| fix.line)
353 .collect();
354 let mut quarantined = Vec::new();
355 for (index, raw) in bytes.split_inclusive(|byte| *byte == b'\n').enumerate() {
356 if remove_lines.contains(&(index + 1)) {
357 quarantined.extend_from_slice(raw);
358 if !raw.ends_with(b"\n") {
359 quarantined.push(b'\n');
360 }
361 }
362 }
363 quarantined
364}
365
366fn planned_fixes(findings: &[Finding]) -> Vec<AppliedFix> {
367 findings
368 .iter()
369 .filter(|finding| finding.fixable)
370 .map(|finding| AppliedFix {
371 line: finding.line,
372 kind: finding.kind.clone(),
373 action: "quarantined".into(),
374 })
375 .collect()
376}
377
378fn add_gitignored_finding(
379 data: &mut DoctorData,
380 resolved: &store::ResolvedFile,
381 file_existed: bool,
382) {
383 if file_existed
384 && let Some(repo) = resolved.repo.as_ref()
385 && resolved.path.starts_with(repo)
386 && Command::new("git")
387 .arg("-C")
388 .arg(repo)
389 .args(["check-ignore", "-q", "--"])
390 .arg(&resolved.path)
391 .stdout(Stdio::null())
392 .stderr(Stdio::null())
393 .status()
394 .is_ok_and(|status| status.success())
395 {
396 data.findings.push(finding(
397 0,
398 "gitignored",
399 "blotter file is gitignored; blotter will not appear in diffs",
400 ));
401 data.healthy = false;
402 }
403}
404
405fn empty_data() -> DoctorData {
406 DoctorData {
407 healthy: true,
408 findings: Vec::new(),
409 checked_lines: 0,
410 fix: None,
411 }
412}
413
414fn finding(line: usize, kind: impl Into<String>, message: impl Into<String>) -> Finding {
415 let kind = kind.into();
416 Finding {
417 line,
418 fixable: matches!(kind.as_str(), "torn_line" | "malformed" | "conflict_marker"),
419 kind,
420 message: message.into(),
421 }
422}
423
424fn inspect(bytes: &[u8], leak_scan: Option<&LeakScan<'_>>) -> DoctorData {
425 let mut findings = Vec::new();
426 let mut leak_findings = Vec::new();
427 let mut records = HashMap::<String, Vec<u8>>::new();
428 let mut record_kinds = HashMap::<String, &'static str>::new();
429 let mut resolves = Vec::<(usize, LogEvent)>::new();
430 let mut promotion_sources = store::PromotionSources::new();
434 let mut promotion_lines = Vec::<(usize, String, Vec<String>)>::new();
435 let mut checked_lines = 0;
436 let unsupported = store::probe_version(bytes);
443 for scanned in store::scan(bytes) {
444 checked_lines += 1;
445 let line = scanned.line;
446 if let Some(leak_scan) = leak_scan {
447 add_leak_findings(&mut leak_findings, line, scanned.raw, leak_scan);
448 }
449 if unsupported.is_some() {
450 continue;
451 }
452 match scanned.event {
453 Err(store::ScanIssue::Torn) => findings.push(finding(
454 line,
455 "torn_line",
456 "final physical line is not newline-terminated",
457 )),
458 Err(store::ScanIssue::Malformed(message)) => {
459 if scanned.raw.starts_with(b"<<<<<<< ") || scanned.raw.starts_with(b">>>>>>> ") {
460 findings.push(finding(
461 line,
462 "conflict_marker",
463 "complete git conflict-marker line found",
464 ));
465 } else {
466 findings.push(finding(line, "malformed", message));
467 }
468 }
469 Err(store::ScanIssue::Unknown(kind)) => findings.push(finding(
470 line,
471 "unknown_kind",
472 kind.map_or_else(
473 || "event has no string kind field".into(),
474 |kind| format!("unknown event kind '{kind}'"),
475 ),
476 )),
477 Ok(event) => match event {
478 LogEvent::Cut {
479 id,
480 ts,
481 agent,
482 text,
483 tags,
484 impact,
485 ..
486 } => {
487 if id
488 .get(..3)
489 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bl_"))
490 {
491 let expected = compute_id(&ts, &agent, &text, impact, &tags);
492 if id != expected {
493 findings.push(finding(
494 line,
495 "id_conflict",
496 format!("cut ID {id} does not recompute to {expected}"),
497 ));
498 }
499 }
500 if let Some(first) = records.get(&id) {
501 let (kind, message) = if first == scanned.raw {
502 (
503 "duplicate_cut",
504 format!("byte-identical duplicate cut {id}"),
505 )
506 } else {
507 (
508 "id_conflict",
509 format!(
510 "cut {id} has a different payload than its first occurrence"
511 ),
512 )
513 };
514 findings.push(finding(line, kind, message));
515 } else {
516 records.insert(id.clone(), scanned.raw.to_vec());
517 }
518 record_kinds.entry(id).or_insert("cut");
521 }
522 LogEvent::Dogear {
523 id,
524 ts,
525 agent,
526 text,
527 tags,
528 ..
529 } => {
530 if id
531 .get(..3)
532 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bl_"))
533 {
534 let mut tags = tags;
535 tags.sort();
536 let expected = compute_dogear_id(&ts, &agent, &text, &tags);
537 if id != expected {
538 findings.push(finding(
539 line,
540 "id_conflict",
541 format!("dogear ID {id} does not recompute to {expected}"),
542 ));
543 }
544 }
545 if let Some(first) = records.get(&id) {
546 let (kind, message) = if first == scanned.raw {
547 (
548 "duplicate_dogear",
549 format!("byte-identical duplicate dogear {id}"),
550 )
551 } else {
552 (
553 "id_conflict",
554 format!(
555 "dogear {id} has a different payload than its first occurrence"
556 ),
557 )
558 };
559 findings.push(finding(line, kind, message));
560 } else {
561 records.insert(id.clone(), scanned.raw.to_vec());
562 }
563 record_kinds.entry(id).or_insert("dogear");
566 }
567 LogEvent::Promotion {
568 id,
569 ts,
570 agent,
571 sources,
572 artifact,
573 ..
574 } => {
575 let sources = normalized(&sources);
576 if id
577 .get(..3)
578 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bl_"))
579 {
580 let expected = compute_promotion_id(
581 &ts,
582 &agent,
583 &sources,
584 artifact.kind.as_str(),
585 &artifact.r#ref,
586 );
587 if id != expected {
588 findings.push(finding(
589 line,
590 "id_conflict",
591 format!("promotion ID {id} does not recompute to {expected}"),
592 ));
593 }
594 }
595 if let Some(first) = records.get(&id) {
596 let (kind, message) = if first == scanned.raw {
597 (
598 "duplicate_promotion",
599 format!("byte-identical duplicate promotion {id}"),
600 )
601 } else {
602 (
603 "id_conflict",
604 format!(
605 "promotion {id} has a different payload than its first occurrence"
606 ),
607 )
608 };
609 findings.push(finding(line, kind, message));
610 } else {
611 records.insert(id.clone(), scanned.raw.to_vec());
612 promotion_sources.insert(id.clone(), sources.clone());
613 promotion_lines.push((line, id.clone(), sources));
614 }
615 record_kinds.entry(id).or_insert("promotion");
616 }
617 LogEvent::Resolve { .. } => resolves.push((line, event)),
618 LogEvent::Unknown => unreachable!("scanner classifies unknown events"),
619 },
620 }
621 }
622 for (line, id, sources) in promotion_lines {
626 for source in sources {
627 let message = match record_kinds.get(&source) {
628 Some(&"cut") => continue,
629 Some(kind) => {
630 format!("promotion {id} names source {source}, which is a {kind}, not a cut")
631 }
632 None => format!("promotion {id} names source {source}, which is in no record"),
633 };
634 findings.push(finding(line, "dangling_source", message));
635 }
636 }
637 let mut base_resolve_ids = HashSet::new();
641 let mut checked_resolves = Vec::new();
642 for (line, event) in resolves {
643 let LogEvent::Resolve { id, amend, .. } = &event else {
644 unreachable!("only resolve events are collected here")
645 };
646 let broken = record_kinds
648 .get(id)
649 .map(|kind| store::broken_resolution_rules(&event, kind, &promotion_sources))
650 .unwrap_or_default();
651 if broken.is_empty() && !*amend {
652 base_resolve_ids.insert(id.clone());
653 }
654 checked_resolves.push((line, id.clone(), *amend, broken));
655 }
656 for (line, id, amend, broken) in checked_resolves {
657 if !broken.is_empty() {
658 findings.push(finding(
659 line,
660 "invalid_resolution",
661 format!("invalid resolution for {id}: {}", broken.join("; ")),
662 ));
663 continue;
664 }
665 let message = if !record_kinds.contains_key(&id) {
666 Some(format!("resolve references unknown record {id}"))
667 } else if amend && !base_resolve_ids.contains(&id) {
668 Some(format!(
669 "amend references record {id} without a base resolve"
670 ))
671 } else {
672 None
673 };
674 if let Some(message) = message {
675 findings.push(finding(line, "orphan_resolve", message));
676 }
677 }
678 if let Some(probe) = unsupported {
679 findings = vec![finding(
680 probe.line,
681 "unsupported_version",
682 unsupported_log_version_message(probe.line, probe.found_version.as_ref()),
683 )];
684 }
685 findings.extend(leak_findings);
686 DoctorData {
687 healthy: findings.is_empty(),
688 findings,
689 checked_lines,
690 fix: None,
691 }
692}
693
694fn current_home_path() -> Option<Vec<u8>> {
695 let cwd = std::env::current_dir().ok()?;
696 store::home_dir(&cwd)
697 .filter(|home| home.is_absolute())
698 .and_then(|home| home.to_str().map(|home| home.as_bytes().to_vec()))
699}
700
701fn home_path_delimiter(byte: u8) -> bool {
709 byte.is_ascii_whitespace() || EVIDENCE_DELIMITERS.contains(&byte) || byte == b':'
710}
711
712fn path_prefix_boundary(bytes: &[u8], end: usize, separator: u8) -> bool {
713 bytes
714 .get(end)
715 .is_none_or(|byte| *byte == b'/' || *byte == separator || home_path_delimiter(*byte))
716}
717
718fn exact_home_boundary(bytes: &[u8], end: usize, separator: u8, dash_home: Option<&[u8]>) -> bool {
723 let Some(byte) = bytes.get(end).copied() else {
724 return true;
725 };
726 if byte == b'/' || byte == b'~' || home_path_delimiter(byte) {
727 return true;
728 }
729 if byte != b'-' {
730 return false;
731 }
732 let rest = &bytes[end..];
733 separator == b'-'
734 || dash_home.is_some_and(|dash| rest.starts_with(dash))
735 || rest.starts_with(b"-Users-")
736 || rest.starts_with(b"-home-")
737}
738
739fn is_redaction_marker_component(component: &[u8]) -> bool {
744 component.strip_prefix(b"~").is_some_and(|tail| {
745 tail.is_empty()
746 || tail.starts_with(b"~")
747 || tail.starts_with(b"-")
748 || tail.starts_with(crate::commands::add::SECRET_MARKER.as_bytes())
749 })
750}
751
752fn is_raw_marker_component(component: &[u8]) -> bool {
755 component.strip_prefix(b"~").is_some_and(|tail| {
756 tail.is_empty()
757 || tail.starts_with(b"~")
758 || tail.starts_with(crate::commands::add::SECRET_MARKER.as_bytes())
759 })
760}
761
762fn generic_home_path_end(
763 bytes: &[u8],
764 start: usize,
765 marker_accepted: fn(&[u8]) -> bool,
766) -> Option<usize> {
767 let prefix = HOME_PREFIXES
768 .into_iter()
769 .find(|prefix| bytes[start..].starts_with(prefix))?;
770 let separator = prefix[0];
771 if start != 0
775 && !bytes
776 .get(start - 1)
777 .is_some_and(|byte| home_path_delimiter(*byte) || (separator == b'-' && *byte == b'/'))
778 {
779 return None;
780 }
781 let component_start = start + prefix.len();
782 let mut component_end = component_start;
783 while let Some(byte) = bytes.get(component_end) {
784 if *byte == b'/' || *byte == separator || home_path_delimiter(*byte) {
785 break;
786 }
787 component_end += 1;
788 }
789 if marker_accepted(&bytes[component_start..component_end]) {
797 return None;
798 }
799 (component_end > component_start && path_prefix_boundary(bytes, component_end, separator))
800 .then_some(component_end)
801}
802
803fn contains_home_path(
804 bytes: &[u8],
805 home: Option<&[u8]>,
806 dash_home: Option<&[u8]>,
807 decoded: bool,
808) -> bool {
809 let marker_accepted: fn(&[u8]) -> bool = if decoded {
810 is_redaction_marker_component
811 } else {
812 is_raw_marker_component
813 };
814 let boundary = |end: usize, separator: u8| {
815 if decoded {
816 exact_home_boundary(bytes, end, separator, dash_home)
817 } else {
818 path_prefix_boundary(bytes, end, separator)
819 }
820 };
821 let mut start = 0;
822 while start < bytes.len() {
823 let home_end = home
824 .filter(|home| bytes[start..].starts_with(home))
825 .map(|home| start + home.len())
826 .filter(|end| boundary(*end, b'/'));
827 let dash_home_end = dash_home
831 .filter(|dash| bytes[start..].starts_with(dash))
832 .map(|dash| start + dash.len())
833 .filter(|end| boundary(*end, b'-'));
834 if home_end.is_some()
835 || dash_home_end.is_some()
836 || generic_home_path_end(bytes, start, marker_accepted).is_some()
837 {
838 return true;
839 }
840 start += 1;
841 }
842 false
843}
844
845fn decoded_contains_home_path(value: &serde_json::Value, leak_scan: &LeakScan<'_>) -> bool {
850 let scan = |text: &str| {
851 contains_home_path(
852 text.as_bytes(),
853 leak_scan.home.as_deref(),
854 leak_scan.dash_home.as_deref(),
855 true,
856 )
857 };
858 match value {
859 serde_json::Value::String(text) => scan(text),
860 serde_json::Value::Array(items) => items
861 .iter()
862 .any(|item| decoded_contains_home_path(item, leak_scan)),
863 serde_json::Value::Object(fields) => fields
864 .iter()
865 .any(|(key, field)| scan(key) || decoded_contains_home_path(field, leak_scan)),
866 _ => false,
867 }
868}
869
870fn add_leak_findings(
871 findings: &mut Vec<Finding>,
872 line: usize,
873 raw: &[u8],
874 leak_scan: &LeakScan<'_>,
875) {
876 let leaked = match serde_json::from_slice::<serde_json::Value>(raw) {
882 Ok(value) => decoded_contains_home_path(&value, leak_scan),
883 Err(_) => contains_home_path(
884 raw,
885 leak_scan.home.as_deref(),
886 leak_scan.dash_home.as_deref(),
887 false,
888 ),
889 };
890 if leaked {
891 findings.push(finding(
892 line,
893 "leak",
894 format!("line {line} contains home path"),
895 ));
896 }
897 for pattern in leak_scan.deny {
898 let matches = raw
899 .windows(pattern.len())
900 .any(|candidate| candidate == pattern.as_bytes());
901 if matches {
902 findings.push(finding(
903 line,
904 "leak",
905 format!("line {line} contains deny pattern {pattern:?}"),
906 ));
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 fn cut(id: &str) -> String {
916 format!(
917 r#"{{"v":2,"kind":"cut","id":"{id}","ts":"2026-01-15T00:00:00.000Z","agent":"t","text":"x","tags":[],"impact":"low","cwd":"."}}"#
918 )
919 }
920
921 fn resolve(id: &str) -> String {
922 format!(
923 r#"{{"v":2,"kind":"resolve","id":"{id}","ts":"2026-01-15T00:00:01.000Z","agent":"t","note":null,"disposition":"fixed","disposition_ts":"2026-01-15T00:00:01.000Z"}}"#
924 )
925 }
926
927 #[test]
931 fn derived_post_fix_report_matches_a_full_reinspection() {
932 let cases: Vec<Vec<u8>> = vec![
933 b"".to_vec(),
934 b"\n".to_vec(),
935 b"not-json\n".to_vec(),
936 b"not-json".to_vec(),
937 format!("\n{}\nnot-json\n", cut("bl_aaaaaaaaaaaaaaaaaaaa")).into_bytes(),
938 format!("not-json\n{}\n{}\n", cut("bl_a"), cut("bl_a")).into_bytes(),
939 format!("{}\nnot-json\n{}\n", cut("bl_a"), cut("bl_a")).into_bytes(),
940 format!("{}\n{}\nnot-json", cut("bl_a"), resolve("bl_b")).into_bytes(),
941 format!(
942 "<<<<<<< HEAD\n{}\n>>>>>>> other\n{}\n",
943 cut("bl_a"),
944 resolve("bl_zzz")
945 )
946 .into_bytes(),
947 br#"{"v":2,"kind":"nope"}"#.to_vec(),
948 format!("{{\"v\":2,\"kind\":\"nope\"}}\nnot-json\n{}\n", cut("bl_a")).into_bytes(),
949 b"not-json\nnot-json\nnot-json\n".to_vec(),
950 format!("{}\n{}\n", cut("bl_a"), resolve("bl_a")).into_bytes(),
951 ];
952 for bytes in cases {
953 let before = inspect(&bytes, None);
954 let applied = planned_fixes(&before.findings);
955 let repaired = repaired_bytes(&bytes, &applied);
956 let expected = serde_json::to_value(inspect(&repaired, None)).unwrap();
957 let derived = serde_json::to_value(post_fix_data(&before, &applied)).unwrap();
958 assert_eq!(
959 derived,
960 expected,
961 "derived report drifted for {:?}",
962 String::from_utf8_lossy(&bytes)
963 );
964 }
965 }
966}