Skip to main content

blotter/commands/
doctor.rs

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.";
15// Byte mirror of `redact::EVIDENCE_DELIMITERS` for raw leak scans.
16// A slash is a path parent, not a delimiter.
17const EVIDENCE_DELIMITERS: &[u8] = b",;)]}&#\"'";
18// Byte mirror of `redact::HOME_PREFIXES`.
19const 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        // Byte mirror of `redact::rewrite_home_paths`: the root home's dash
98        // spelling is a bare `-`, not an encoding, and is never matched.
99        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    // A symlinked log is locked and read through the link; the swap must land
216    // on the target, not replace the link with a regular file.
217    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    // `append_file` extends an existing quarantine sidecar, so its rollback
225    // restores the prior length instead of deleting an earlier repair's lines.
226    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            // A failed append into a pre-existing sidecar leaves partial
234            // bytes behind; truncate back alongside removing the backup.
235            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    // The swap renames a new inode over the path, so the held lock no longer
256    // covers the file there; diagnose the bytes just written, never a reread —
257    // and derive that diagnosis from the pre-fix findings rather than parsing
258    // those bytes a second time.
259    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
272/// Mirror of `archive::remove_created_outputs` for an aborted repair: a
273/// sidecar this run created is removed, and one it only extended is truncated
274/// back. Without it a failed repair leaves a backup that claims a repair which
275/// never happened, and the retry then fails on that leftover.
276fn 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
292/// What `inspect` would report on the repaired bytes, derived from the pre-fix
293/// report instead of decoding the whole log again.
294///
295/// `repaired_bytes` only drops whole quarantined lines, so the surviving
296/// findings are exactly the ones no fix removed, each renumbered by the lines
297/// dropped ahead of it. Nothing that survives depended on a dropped line: only a
298/// scan error is fixable, a scan error is the only finding its line can carry,
299/// and a line that failed to parse contributes no record, no duplicate payload,
300/// no resolve target and no base resolve. Dropping a line also cannot tear the tail or orphan
301/// the leading empty segment, because each dropped line takes its own newline.
302fn 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    // The promotion `sources[]` rules (5) and (6) join against, plus the lines
431    // `dangling_source` reports on. Both need the completed `record_kinds`, so
432    // they are decided after the scan.
433    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    // The version probe outranks every record-model classification for this file
437    // (r48/r50): malformed, torn, unknown-kind, duplicate, orphan and
438    // invalid-resolution findings are all replaced by one `unsupported_version`
439    // finding. The walk still happens, so `checked_lines` stays honest and the
440    // `--leaks` byte scan still runs — refusing a leak audit is a privacy
441    // regression the version boundary does not justify.
442    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                    // The fold keeps the first record for an ID, so the kind a
519                    // later resolve is judged against is the first one too.
520                    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                    // The fold keeps the first record for an ID, so the kind a
564                    // later resolve is judged against is the first one too.
565                    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    // Every source must resolve, in the folded log, to a cut (r48). Non-fixable:
623    // only a human knows whether the cut was wrongly archived or the promotion
624    // wrongly written.
625    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    // Validity is decided after the join, and an invalid event is discarded
638    // before winners are chosen (r50), so the base-resolve set an orphan amend is
639    // judged against holds valid base resolves only.
640    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        // An orphan joins to no record and is therefore never invalid.
647        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
701// Byte mirror of `redact::home_path_delimiter`: the shared evidence delimiters
702// plus the colon that separates entries in Unix path lists such as PATH. Both
703// classes must stay in sync with `redact.rs`, where the colon is deliberately
704// kept out of `evidence_delimiter` so secret-value and URL parsing are
705// unchanged. `commands::add::SECRET_MARKER` joins them: `generic_home_path_end`
706// below accepts that marker's bytes behind a generic prefix (r41), so the
707// scanner now mirrors the secret pass as well as the home pass.
708fn 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
718// The end boundary of an exact current-home match on the decoded layer: the
719// byte mirror of `redact::exact_home_boundary` (r42). The raw layer keeps
720// `path_prefix_boundary` — it makes no new acceptance claims and its rule set
721// is exactly what shipped before r43.
722fn 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
739// The decoded-layer marker enumeration, complete after r39, r41 and r42: the
740// bare marker, or a leading `~` immediately followed by another `~`, by a `-`,
741// or by the secret marker, whatever follows after. Every other component is
742// bytes the redactor never wrote, so `~abc` and `~<reda` stay leaks.
743fn 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
752// The raw layer's acceptance, frozen at what shipped before r43: r39's bare
753// marker and r41's `~~`/`~<redacted>` compositions, with no r42 member.
754fn 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    // Generic aliases only start a token. Unlike exact $HOME matching, a
772    // preceding slash makes the slash form a nested path such as
773    // /mnt/home/shared; a dash-encoded slug normally does follow a slash.
774    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    // A `~` is blotter's own redaction marker, not a username: the redactor
790    // emits it behind a generic prefix whose own component was empty. It does
791    // not always stand alone — r38's resume-after-match lets two home forms
792    // abut as `~~`, and the secret pass (r25) runs afterwards over a token
793    // class holding no `~`, so it can swallow the separator behind one and
794    // leave `~<redacted>`. The accepted set differs by layer, so the caller
795    // passes it: r42's `~-` member is sound only on the decoded text.
796    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        // Exact current home in dash-encoded form; mirrors the redaction-side
828        // precedence so dashed usernames and non-generic homes are caught, and
829        // like the slash form it carries no start boundary (r40).
830        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
845// Every string a parsed line holds — object keys and values alike, at every
846// depth. The typed `LogEvent` drops unknown fields and r24 has unknown stored
847// values pass through opaquely, so scanning the typed record would silently
848// stop covering a field a future release adds.
849fn 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    // A line that parses is scanned as decoded text — the same bytes the
877    // redactor wrote and `list` renders, so the mirror means the same thing on
878    // both sides (r43). A line that does not parse keeps the raw scan, rules
879    // frozen: r22 has the gate cover malformed lines, and a false flag there
880    // fails safe. The split is on parse success, not record validity.
881    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    /// The derived post-fix report must equal a full reinspection of the
928    /// repaired bytes on every shape, including the ones where line numbers
929    /// shift, the log keeps a leading empty segment, or the repair empties it.
930    #[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}