Skip to main content

pray_core/verify/
mod.rs

1mod format;
2mod integrity;
3pub mod position;
4mod provisioned;
5
6use crate::hashing::{checksum_managed_body_line_refs, normalize_line_endings};
7use crate::lockfile::{Lockfile, ManagedSpanRecord};
8use crate::render::render_project;
9use crate::resolve::ResolvedProject;
10use crate::{PrayError, PrayResult};
11use format::format_drift_report;
12pub use format::format_verification_report;
13use integrity::push_package_lock_findings;
14use position::{format_position_drift_message, summarize_position_drift};
15use std::collections::{BTreeMap, BTreeSet, HashSet};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct VerificationFinding {
19    pub kind: String,
20    pub message: String,
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct VerificationReport {
25    pub findings: Vec<VerificationFinding>,
26}
27
28impl VerificationReport {
29    pub fn is_clean(&self) -> bool {
30        self.findings.is_empty()
31    }
32
33    pub fn has_warnings(&self) -> bool {
34        self.findings.iter().any(VerificationFinding::is_warning)
35    }
36
37    pub fn has_errors(&self) -> bool {
38        self.findings.iter().any(VerificationFinding::is_error)
39    }
40}
41
42impl VerificationFinding {
43    pub fn is_warning(&self) -> bool {
44        matches!(self.kind.as_str(), "orphan_marker")
45    }
46
47    pub fn is_error(&self) -> bool {
48        !self.is_warning()
49    }
50}
51
52pub fn inspect_project(
53    project: &ResolvedProject,
54    lockfile: &Lockfile,
55) -> PrayResult<VerificationReport> {
56    let (report, _, _) = collect_verification_report(project, lockfile)?;
57    Ok(report)
58}
59
60pub fn verify_project(
61    project: &ResolvedProject,
62    lockfile: &Lockfile,
63    strict: bool,
64) -> PrayResult<VerificationReport> {
65    let report = inspect_project(project, lockfile)?;
66    if report.is_clean() {
67        return Ok(report);
68    }
69
70    if strict || report.has_errors() {
71        Err(PrayError::Verify(format_verification_report(&report)))
72    } else {
73        Ok(report)
74    }
75}
76
77type CollectedVerification = (
78    VerificationReport,
79    BTreeMap<String, String>,
80    BTreeMap<String, String>,
81);
82
83fn collect_verification_report(
84    project: &ResolvedProject,
85    lockfile: &Lockfile,
86) -> PrayResult<CollectedVerification> {
87    let mut report = VerificationReport::default();
88    let mut rendered_targets = BTreeMap::new();
89    let fresh_targets: BTreeMap<String, String> = render_project(project)?
90        .into_iter()
91        .map(|target| (target.path.to_string_lossy().to_string(), target.content))
92        .collect();
93    if project.manifest_hash != lockfile.manifest_hash {
94        report.findings.push(VerificationFinding {
95            kind: "verify_error".to_string(),
96            message:
97                "Prayfile changed since `Prayfile.lock` was generated. Run `pray install` to refresh the lockfile."
98                    .to_string(),
99        });
100    }
101
102    push_package_lock_findings(project, lockfile, &mut report.findings);
103
104    let mut target_spans: BTreeMap<String, Vec<&ManagedSpanRecord>> = BTreeMap::new();
105    for span in &lockfile.managed_span {
106        target_spans
107            .entry(span.target.clone())
108            .or_default()
109            .push(span);
110    }
111
112    for (target_path, spans) in target_spans {
113        let absolute_path = project.project_root.join(&target_path);
114        if !absolute_path.exists() {
115            report.findings.push(VerificationFinding {
116                kind: "verify_error".to_string(),
117                message: format!(
118                    "Rendered file `{}` is missing. Run `pray install` to generate it.",
119                    target_path
120                ),
121            });
122            continue;
123        }
124        let text = crate::render_file::read_destination_text(&absolute_path)?;
125        rendered_targets.insert(target_path.clone(), text.clone());
126        let lines: Vec<&str> = text.lines().collect();
127        let markers = marker_positions(&lines);
128        for span in &spans {
129            match markers.get(&span.id) {
130                None => report.findings.push(VerificationFinding {
131                    kind: "removed_prayer".to_string(),
132                    message: format!(
133                        "`{}` is missing managed marker `{}` for `{}::{}`. Run `pray install` to restore the managed span.",
134                        target_path, span.id, span.package, span.export
135                    ),
136                }),
137                Some((_, _, checksum)) => {
138                    if checksum != &span.ideal_checksum {
139                        report.findings.push(VerificationFinding {
140                            kind: "custom_implementation".to_string(),
141                            message: format!(
142                                "`{}` marker `{}` (`{}::{}`) was edited. Restore the managed block or run `pray install` to regenerate it.",
143                                target_path, span.id, span.package, span.export
144                            ),
145                        });
146                    }
147                }
148            }
149        }
150        let fresh_lines: Vec<&str> = fresh_targets
151            .get(&target_path)
152            .map(|fresh| fresh.lines().collect())
153            .unwrap_or_default();
154        if let Some(summary) = summarize_position_drift(
155            &target_path,
156            &spans,
157            &markers,
158            &lines,
159            fresh_targets
160                .contains_key(&target_path)
161                .then_some(fresh_lines.as_slice()),
162            &project.local_files,
163        ) {
164            report.findings.push(VerificationFinding {
165                kind: "position_drift".to_string(),
166                message: format_position_drift_message(&summary),
167            });
168        }
169        for finding in find_orphan_marker_findings_from_markers(&spans, &markers, &target_path) {
170            report.findings.push(finding);
171        }
172    }
173
174    provisioned::push_provisioned_and_local_findings(project, lockfile, &mut report.findings)?;
175
176    Ok((report, rendered_targets, fresh_targets))
177}
178
179pub fn find_orphan_marker_findings(
180    spans: &[&ManagedSpanRecord],
181    lines: &[&str],
182    target_path: &str,
183) -> Vec<VerificationFinding> {
184    let markers = marker_positions(lines);
185    find_orphan_marker_findings_from_markers(spans, &markers, target_path)
186}
187
188fn find_orphan_marker_findings_from_markers(
189    spans: &[&ManagedSpanRecord],
190    markers: &BTreeMap<String, (usize, usize, String)>,
191    target_path: &str,
192) -> Vec<VerificationFinding> {
193    let tracked_ids: HashSet<&str> = spans.iter().map(|span| span.id.as_str()).collect();
194    let mut findings = Vec::new();
195    for marker_id in markers.keys() {
196        if marker_id != "0" && !tracked_ids.contains(marker_id.as_str()) {
197            findings.push(VerificationFinding {
198                kind: "orphan_marker".to_string(),
199                message: format!(
200                    "`{}` contains marker `{}` that is not tracked in `Prayfile.lock`. Remove the marker or run `pray install` to reconcile.",
201                    target_path, marker_id
202                ),
203            });
204        }
205    }
206    findings
207}
208
209pub fn drift_project(
210    project: &ResolvedProject,
211    lockfile: &Lockfile,
212) -> PrayResult<VerificationReport> {
213    let (mut report, rendered_targets, fresh_targets) =
214        collect_verification_report(project, lockfile)?;
215
216    let lock_targets = lockfile_targets(lockfile);
217    for (path, fresh_content) in &fresh_targets {
218        let normalized_fresh = normalize_line_endings(fresh_content);
219        let on_disk = rendered_targets
220            .get(path)
221            .map(|text| normalize_line_endings(text));
222        let matches = on_disk.as_ref() == Some(&normalized_fresh);
223        if !matches {
224            report.findings.push(VerificationFinding {
225                kind: "renderer_drift".to_string(),
226                message: format!("{path} differs from fresh render"),
227            });
228        }
229        if !lock_targets.contains(path) {
230            report.findings.push(VerificationFinding {
231                kind: "renderer_drift".to_string(),
232                message: format!("{path} is not tracked in lockfile"),
233            });
234        }
235    }
236
237    if report.findings.is_empty() {
238        Ok(report)
239    } else {
240        Err(PrayError::Verify(format_drift_report(&report)))
241    }
242}
243
244fn marker_positions(lines: &[&str]) -> BTreeMap<String, (usize, usize, String)> {
245    let mut markers = BTreeMap::new();
246    let mut active: Option<(String, usize, Vec<&str>)> = None;
247    for (index, line) in lines.iter().enumerate() {
248        match parse_marker(line) {
249            None => {
250                if let Some((_, _, body)) = active.as_mut() {
251                    body.push(line);
252                }
253            }
254            Some(ParsedMarker::Ignore) => {}
255            Some(ParsedMarker::Id(id)) => match active.take() {
256                None => {
257                    active = Some((id.to_string(), index + 1, Vec::new()));
258                }
259                Some((open_id, open_line, body)) if open_id == id => {
260                    let checksum = checksum_managed_body_line_refs(&body);
261                    markers.insert(open_id, (open_line, index + 1, checksum));
262                }
263                Some(previous) => {
264                    active = Some(previous);
265                }
266            },
267        }
268    }
269    markers
270}
271
272enum ParsedMarker<'a> {
273    Ignore,
274    Id(&'a str),
275}
276
277fn parse_marker(line: &str) -> Option<ParsedMarker<'_>> {
278    let trimmed = line.trim();
279    let remainder = trimmed.strip_prefix("<!-- pray:")?;
280    let id = remainder.strip_suffix(" -->")?;
281    if id == "0 ignore-comments" {
282        return Some(ParsedMarker::Ignore);
283    }
284    if id
285        .chars()
286        .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
287    {
288        return Some(ParsedMarker::Id(id));
289    }
290    None
291}
292
293fn lockfile_targets(lockfile: &Lockfile) -> BTreeSet<String> {
294    lockfile
295        .target
296        .iter()
297        .flat_map(|target| target.outputs.iter().cloned())
298        .collect()
299}