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_regular_bytes, symlink_error,
6    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::{Read, 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 relative = validate_destination_path(&file.path.to_string_lossy())?;
66    ensure_safe_destination_ancestors(
67        &project.project_root,
68        relative.as_path(),
69        relative.as_str(),
70    )?;
71    let destination = relative.join_root(&project.project_root);
72    let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
73    let normalized = lock_path(relative.as_path());
74    let previous = previous_lockfile.and_then(|lockfile| {
75        lockfile
76            .provisioned
77            .iter()
78            .find(|record| record.path == normalized)
79    });
80    classify_destination(&destination, &normalized, &expected, previous)
81}
82
83fn previous_lock_map(lockfile: Option<&Lockfile>) -> BTreeMap<String, ProvisionedFileRecord> {
84    lockfile
85        .map(|lockfile| {
86            lockfile
87                .provisioned
88                .iter()
89                .map(|record| (record.path.clone(), record.clone()))
90                .collect()
91        })
92        .unwrap_or_default()
93}
94
95fn write_provisioned_leaf(
96    project: &ResolvedProject,
97    file: &PlannedProvisionedFile,
98    previous: &BTreeMap<String, ProvisionedFileRecord>,
99) -> PrayResult<()> {
100    let relative = validate_destination_path(&file.path.to_string_lossy())?;
101    ensure_safe_destination_ancestors(
102        &project.project_root,
103        relative.as_path(),
104        relative.as_str(),
105    )?;
106    let destination = relative.join_root(&project.project_root);
107    let path_text = lock_path(relative.as_path());
108    let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
109    let record = previous.get(&path_text);
110    match classify_destination(&destination, &path_text, &expected, record)? {
111        ProvisionedDestinationStatus::Missing => {
112            if let Some(parent) = destination.parent() {
113                fs::create_dir_all(parent)?;
114            }
115            ensure_safe_destination_ancestors(
116                &project.project_root,
117                relative.as_path(),
118                relative.as_str(),
119            )?;
120            create_regular_bytes(&destination, &path_text, &expected)
121        }
122        ProvisionedDestinationStatus::Unchanged => Ok(()),
123        ProvisionedDestinationStatus::ManagedUpdate => {
124            let Some(record) = record else {
125                return Err(PrayError::Render(format!(
126                    "missing lock ownership for `{path_text}`"
127                )));
128            };
129            ensure_safe_destination_ancestors(
130                &project.project_root,
131                relative.as_path(),
132                relative.as_str(),
133            )?;
134            update_regular_bytes(&destination, &path_text, &expected, &record.content_hash)
135        }
136    }
137}
138
139fn classify_destination(
140    destination: &Path,
141    path_text: &str,
142    expected: &[u8],
143    previous: Option<&ProvisionedFileRecord>,
144) -> PrayResult<ProvisionedDestinationStatus> {
145    match destination_kind(destination)? {
146        DestinationKind::Missing => Ok(ProvisionedDestinationStatus::Missing),
147        DestinationKind::Regular => {
148            let on_disk = read_regular_bytes(destination, path_text)?;
149            if on_disk == expected {
150                return Ok(ProvisionedDestinationStatus::Unchanged);
151            }
152            if let Some(record) = previous {
153                if sha256_prefixed(&on_disk) == record.content_hash {
154                    return Ok(ProvisionedDestinationStatus::ManagedUpdate);
155                }
156                return Err(PrayError::Render(format!(
157                    "refusing to overwrite `{path_text}`; it was provisioned and then edited"
158                )));
159            }
160            Err(PrayError::Render(format!(
161                "refusing to overwrite `{path_text}`; it already exists and is not the expected provisioned file"
162            )))
163        }
164        DestinationKind::Symlink => Err(symlink_error(path_text)),
165        DestinationKind::Other => Err(PrayError::Render(format!(
166            "refusing to write `{path_text}`; destination is not a regular file"
167        ))),
168    }
169}
170
171fn prune_dropped_leaves(
172    project: &ResolvedProject,
173    previous: &Lockfile,
174    planned_paths: &BTreeSet<String>,
175) -> PrayResult<()> {
176    for record in &previous.provisioned {
177        if planned_paths.contains(&record.path) {
178            continue;
179        }
180        let relative = validate_destination_path(&record.path)?;
181        ensure_safe_destination_ancestors(
182            &project.project_root,
183            relative.as_path(),
184            relative.as_str(),
185        )?;
186        let destination = relative.join_root(&project.project_root);
187        match destination_kind(&destination)? {
188            DestinationKind::Regular => {
189                let on_disk = read_regular_bytes(&destination, &record.path)?;
190                if sha256_prefixed(&on_disk) == record.content_hash {
191                    ensure_safe_destination_ancestors(
192                        &project.project_root,
193                        relative.as_path(),
194                        relative.as_str(),
195                    )?;
196                    fs::remove_file(&destination)?;
197                }
198            }
199            DestinationKind::Missing | DestinationKind::Symlink | DestinationKind::Other => {}
200        }
201    }
202    Ok(())
203}
204
205fn lock_path(path: &Path) -> String {
206    path.to_string_lossy().replace('\\', "/")
207}
208
209fn update_regular_bytes(
210    path: &Path,
211    display: &str,
212    bytes: &[u8],
213    authorized_hash: &str,
214) -> PrayResult<()> {
215    let mut file = open_regular(path, display, true)?;
216    let mut on_disk = Vec::new();
217    file.read_to_end(&mut on_disk)?;
218    if on_disk == bytes {
219        return Ok(());
220    }
221    if sha256_prefixed(&on_disk) != authorized_hash {
222        return Err(PrayError::Render(format!(
223            "refusing to overwrite `{display}`; it was provisioned and then edited"
224        )));
225    }
226    file.rewind()?;
227    file.set_len(0)?;
228    file.write_all(bytes)?;
229    Ok(())
230}