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}