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};
16use std::fs;
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 provisioned::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}