Skip to main content

pray_core/
render_dest.rs

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}