Skip to main content

pray_core/verify/
mod.rs

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