1use crate::hashing::sha256_prefixed;
2use crate::lockfile::{Lockfile, ProvisionedFileRecord};
3use crate::paths::validate_destination_path;
4use crate::render_file::{
5 create_regular_bytes, destination_kind, open_regular, read_destination_bytes,
6 read_regular_bytes, symlink_error, DestinationKind,
7};
8use crate::render_path_guard::ensure_safe_destination_ancestors;
9use crate::render_provisioned::{
10 expected_provisioned_bytes, planned_provisioned_files, PlannedProvisionedFile,
11};
12use crate::resolve::ResolvedProject;
13use crate::{PrayError, PrayResult};
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs;
16use std::io::{Seek, Write};
17use std::path::Path;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ProvisionedDestinationStatus {
21 Missing,
22 Unchanged,
23 ManagedUpdate,
24}
25
26pub fn provisioned_lock_records(
27 project: &ResolvedProject,
28) -> PrayResult<Vec<ProvisionedFileRecord>> {
29 let mut records = Vec::new();
30 for file in planned_provisioned_files(project)? {
31 let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
32 records.push(ProvisionedFileRecord {
33 path: file.path.to_string_lossy().replace('\\', "/"),
34 content_hash: sha256_prefixed(&expected),
35 package: file.package,
36 export: file.export,
37 });
38 }
39 Ok(records)
40}
41
42pub fn materialize_provisioned_exports(
43 project: &ResolvedProject,
44 previous_lockfile: Option<&Lockfile>,
45) -> PrayResult<()> {
46 let planned = planned_provisioned_files(project)?;
47 let previous = previous_lock_map(previous_lockfile);
48 let mut planned_paths = BTreeSet::new();
49 for file in &planned {
50 let relative = validate_destination_path(&file.path.to_string_lossy())?;
51 planned_paths.insert(lock_path(relative.as_path()));
52 write_provisioned_leaf(project, file, &previous)?;
53 }
54 if let Some(lockfile) = previous_lockfile {
55 prune_dropped_leaves(project, lockfile, &planned_paths)?;
56 }
57 Ok(())
58}
59
60pub fn provisioned_destination_status(
61 project: &ResolvedProject,
62 file: &PlannedProvisionedFile,
63 previous_lockfile: Option<&Lockfile>,
64) -> PrayResult<ProvisionedDestinationStatus> {
65 let previous = previous_lockfile.and_then(|lockfile| {
66 lockfile
67 .provisioned
68 .iter()
69 .find(|record| record.path == lock_path(&file.path))
70 });
71 destination_status_with_record(project, file, previous)
72}
73
74fn destination_status_with_record(
75 project: &ResolvedProject,
76 file: &PlannedProvisionedFile,
77 previous: Option<&ProvisionedFileRecord>,
78) -> PrayResult<ProvisionedDestinationStatus> {
79 let relative = validate_destination_path(&file.path.to_string_lossy())?;
80 ensure_safe_destination_ancestors(
81 &project.project_root,
82 relative.as_path(),
83 relative.as_str(),
84 )?;
85 let destination = relative.join_root(&project.project_root);
86 let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
87 classify_destination(
88 &destination,
89 &lock_path(relative.as_path()),
90 &expected,
91 previous,
92 )
93}
94
95pub fn provisioned_destination_statuses(
96 project: &ResolvedProject,
97 previous_lockfile: Option<&Lockfile>,
98) -> PrayResult<Vec<(PlannedProvisionedFile, ProvisionedDestinationStatus)>> {
99 let previous = previous_lock_map(previous_lockfile);
100 let mut statuses = Vec::new();
101 let mut errors = Vec::new();
102 let mut omitted = 0;
103 let mut diagnostic_bytes = 0;
104 for file in planned_provisioned_files(project)? {
105 match destination_status_with_record(project, &file, previous.get(&lock_path(&file.path))) {
106 Ok(status) => statuses.push((file, status)),
107 Err(PrayError::Render(message)) => {
108 let message = format!(
109 "{message} (package `{}`, export `{}`)",
110 file.package, file.export
111 );
112 if errors.len() < 100 && diagnostic_bytes + message.len() < 60 * 1024 {
113 diagnostic_bytes += message.len();
114 errors.push(message);
115 } else {
116 omitted += 1;
117 }
118 }
119 Err(error) => return Err(error),
120 }
121 }
122 if omitted > 0 {
123 errors.push(format!("{omitted} additional destination conflicts omitted; resolve the listed paths and run `pray plan` again"));
124 }
125 if errors.is_empty() {
126 Ok(statuses)
127 } else {
128 Err(PrayError::Render(errors.join("\n")))
129 }
130}
131
132fn previous_lock_map(lockfile: Option<&Lockfile>) -> BTreeMap<String, ProvisionedFileRecord> {
133 lockfile
134 .map(|lockfile| {
135 lockfile
136 .provisioned
137 .iter()
138 .map(|record| (record.path.clone(), record.clone()))
139 .collect()
140 })
141 .unwrap_or_default()
142}
143
144fn write_provisioned_leaf(
145 project: &ResolvedProject,
146 file: &PlannedProvisionedFile,
147 previous: &BTreeMap<String, ProvisionedFileRecord>,
148) -> PrayResult<()> {
149 let relative = validate_destination_path(&file.path.to_string_lossy())?;
150 ensure_safe_destination_ancestors(
151 &project.project_root,
152 relative.as_path(),
153 relative.as_str(),
154 )?;
155 let destination = relative.join_root(&project.project_root);
156 let path_text = lock_path(relative.as_path());
157 let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
158 let record = previous.get(&path_text);
159 match classify_destination(&destination, &path_text, &expected, record)? {
160 ProvisionedDestinationStatus::Missing => {
161 if let Some(parent) = destination.parent() {
162 fs::create_dir_all(parent)?;
163 }
164 ensure_safe_destination_ancestors(
165 &project.project_root,
166 relative.as_path(),
167 relative.as_str(),
168 )?;
169 create_regular_bytes(&destination, &path_text, &expected)
170 }
171 ProvisionedDestinationStatus::Unchanged => Ok(()),
172 ProvisionedDestinationStatus::ManagedUpdate => {
173 let Some(record) = record else {
174 return Err(PrayError::Render(format!(
175 "missing lock ownership for `{path_text}`"
176 )));
177 };
178 ensure_safe_destination_ancestors(
179 &project.project_root,
180 relative.as_path(),
181 relative.as_str(),
182 )?;
183 update_regular_bytes(&destination, &path_text, &expected, &record.content_hash)
184 }
185 }
186}
187
188fn classify_destination(
189 destination: &Path,
190 path_text: &str,
191 expected: &[u8],
192 previous: Option<&ProvisionedFileRecord>,
193) -> PrayResult<ProvisionedDestinationStatus> {
194 match destination_kind(destination)? {
195 DestinationKind::Missing => Ok(ProvisionedDestinationStatus::Missing),
196 DestinationKind::Regular => {
197 let on_disk = read_regular_bytes(destination, path_text)?;
198 if on_disk == expected {
199 return Ok(ProvisionedDestinationStatus::Unchanged);
200 }
201 if let Some(record) = previous {
202 if sha256_prefixed(&on_disk) == record.content_hash {
203 return Ok(ProvisionedDestinationStatus::ManagedUpdate);
204 }
205 return Err(PrayError::Render(format!(
206 "refusing to overwrite `{path_text}`; it was written by pray and then edited. Inspect your changes and move the file aside, then run `pray install`"
207 )));
208 }
209 Err(PrayError::Render(format!(
210 "refusing to overwrite `{path_text}`; its existing contents differ from this package. Inspect the file and move it aside, then run `pray install`. If an older pray wrote it, restore the original Prayfile and package version, run `pray install`, then retry the update"
211 )))
212 }
213 DestinationKind::Symlink => Err(symlink_error(path_text)),
214 DestinationKind::Other => Err(PrayError::Render(format!(
215 "refusing to write `{path_text}`; destination is not a regular file"
216 ))),
217 }
218}
219
220fn prune_dropped_leaves(
221 project: &ResolvedProject,
222 previous: &Lockfile,
223 planned_paths: &BTreeSet<String>,
224) -> PrayResult<()> {
225 for record in &previous.provisioned {
226 if planned_paths.contains(&record.path) {
227 continue;
228 }
229 let relative = validate_destination_path(&record.path)?;
230 ensure_safe_destination_ancestors(
231 &project.project_root,
232 relative.as_path(),
233 relative.as_str(),
234 )?;
235 let destination = relative.join_root(&project.project_root);
236 match destination_kind(&destination)? {
237 DestinationKind::Regular => {
238 let on_disk = read_regular_bytes(&destination, &record.path)?;
239 if sha256_prefixed(&on_disk) == record.content_hash {
240 ensure_safe_destination_ancestors(
241 &project.project_root,
242 relative.as_path(),
243 relative.as_str(),
244 )?;
245 if !crate::transaction::replace(&destination, Some(&on_disk), None)? {
246 fs::remove_file(&destination)?;
247 }
248 }
249 }
250 DestinationKind::Missing | DestinationKind::Symlink | DestinationKind::Other => {}
251 }
252 }
253 Ok(())
254}
255
256fn lock_path(path: &Path) -> String {
257 path.to_string_lossy().replace('\\', "/")
258}
259
260fn update_regular_bytes(
261 path: &Path,
262 display: &str,
263 bytes: &[u8],
264 authorized_hash: &str,
265) -> PrayResult<()> {
266 let mut file = open_regular(path, display, true)?;
267 let on_disk = read_destination_bytes(&mut file, display)?;
268 if on_disk == bytes {
269 return Ok(());
270 }
271 if sha256_prefixed(&on_disk) != authorized_hash {
272 return Err(PrayError::Render(format!(
273 "refusing to overwrite `{display}`; it was written by pray and then edited. Inspect your changes and move the file aside, then run `pray install`"
274 )));
275 }
276 if crate::transaction::replace(path, Some(&on_disk), Some(bytes))? {
277 return Ok(());
278 }
279 file.rewind()?;
280 file.set_len(0)?;
281 file.write_all(bytes)?;
282 Ok(())
283}