use crate::hashing::sha256_prefixed;
use crate::lockfile::{Lockfile, ProvisionedFileRecord};
use crate::paths::validate_destination_path;
use crate::render_file::{
create_regular_bytes, destination_kind, open_regular, read_destination_bytes,
read_regular_bytes, symlink_error, DestinationKind,
};
use crate::render_path_guard::ensure_safe_destination_ancestors;
use crate::render_provisioned::{
expected_provisioned_bytes, planned_provisioned_files, PlannedProvisionedFile,
};
use crate::resolve::ResolvedProject;
use crate::{PrayError, PrayResult};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{Seek, Write};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvisionedDestinationStatus {
Missing,
Unchanged,
ManagedUpdate,
}
pub fn provisioned_lock_records(
project: &ResolvedProject,
) -> PrayResult<Vec<ProvisionedFileRecord>> {
let mut records = Vec::new();
for file in planned_provisioned_files(project)? {
let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
records.push(ProvisionedFileRecord {
path: file.path.to_string_lossy().replace('\\', "/"),
content_hash: sha256_prefixed(&expected),
package: file.package,
export: file.export,
});
}
Ok(records)
}
pub fn materialize_provisioned_exports(
project: &ResolvedProject,
previous_lockfile: Option<&Lockfile>,
) -> PrayResult<()> {
let planned = planned_provisioned_files(project)?;
let previous = previous_lock_map(previous_lockfile);
let mut planned_paths = BTreeSet::new();
for file in &planned {
let relative = validate_destination_path(&file.path.to_string_lossy())?;
planned_paths.insert(lock_path(relative.as_path()));
write_provisioned_leaf(project, file, &previous)?;
}
if let Some(lockfile) = previous_lockfile {
prune_dropped_leaves(project, lockfile, &planned_paths)?;
}
Ok(())
}
pub fn provisioned_destination_status(
project: &ResolvedProject,
file: &PlannedProvisionedFile,
previous_lockfile: Option<&Lockfile>,
) -> PrayResult<ProvisionedDestinationStatus> {
let previous = previous_lockfile.and_then(|lockfile| {
lockfile
.provisioned
.iter()
.find(|record| record.path == lock_path(&file.path))
});
destination_status_with_record(project, file, previous)
}
fn destination_status_with_record(
project: &ResolvedProject,
file: &PlannedProvisionedFile,
previous: Option<&ProvisionedFileRecord>,
) -> PrayResult<ProvisionedDestinationStatus> {
let relative = validate_destination_path(&file.path.to_string_lossy())?;
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
let destination = relative.join_root(&project.project_root);
let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
classify_destination(
&destination,
&lock_path(relative.as_path()),
&expected,
previous,
)
}
pub fn provisioned_destination_statuses(
project: &ResolvedProject,
previous_lockfile: Option<&Lockfile>,
) -> PrayResult<Vec<(PlannedProvisionedFile, ProvisionedDestinationStatus)>> {
let previous = previous_lock_map(previous_lockfile);
let mut statuses = Vec::new();
let mut errors = Vec::new();
let mut omitted = 0;
let mut diagnostic_bytes = 0;
for file in planned_provisioned_files(project)? {
match destination_status_with_record(project, &file, previous.get(&lock_path(&file.path))) {
Ok(status) => statuses.push((file, status)),
Err(PrayError::Render(message)) => {
let message = format!(
"{message} (package `{}`, export `{}`)",
file.package, file.export
);
if errors.len() < 100 && diagnostic_bytes + message.len() < 60 * 1024 {
diagnostic_bytes += message.len();
errors.push(message);
} else {
omitted += 1;
}
}
Err(error) => return Err(error),
}
}
if omitted > 0 {
errors.push(format!("{omitted} additional destination conflicts omitted; resolve the listed paths and run `pray plan` again"));
}
if errors.is_empty() {
Ok(statuses)
} else {
Err(PrayError::Render(errors.join("\n")))
}
}
fn previous_lock_map(lockfile: Option<&Lockfile>) -> BTreeMap<String, ProvisionedFileRecord> {
lockfile
.map(|lockfile| {
lockfile
.provisioned
.iter()
.map(|record| (record.path.clone(), record.clone()))
.collect()
})
.unwrap_or_default()
}
fn write_provisioned_leaf(
project: &ResolvedProject,
file: &PlannedProvisionedFile,
previous: &BTreeMap<String, ProvisionedFileRecord>,
) -> PrayResult<()> {
let relative = validate_destination_path(&file.path.to_string_lossy())?;
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
let destination = relative.join_root(&project.project_root);
let path_text = lock_path(relative.as_path());
let expected = expected_provisioned_bytes(&file.source, &project.manifest.symbols)?;
let record = previous.get(&path_text);
match classify_destination(&destination, &path_text, &expected, record)? {
ProvisionedDestinationStatus::Missing => {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
create_regular_bytes(&destination, &path_text, &expected)
}
ProvisionedDestinationStatus::Unchanged => Ok(()),
ProvisionedDestinationStatus::ManagedUpdate => {
let Some(record) = record else {
return Err(PrayError::Render(format!(
"missing lock ownership for `{path_text}`"
)));
};
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
update_regular_bytes(&destination, &path_text, &expected, &record.content_hash)
}
}
}
fn classify_destination(
destination: &Path,
path_text: &str,
expected: &[u8],
previous: Option<&ProvisionedFileRecord>,
) -> PrayResult<ProvisionedDestinationStatus> {
match destination_kind(destination)? {
DestinationKind::Missing => Ok(ProvisionedDestinationStatus::Missing),
DestinationKind::Regular => {
let on_disk = read_regular_bytes(destination, path_text)?;
if on_disk == expected {
return Ok(ProvisionedDestinationStatus::Unchanged);
}
if let Some(record) = previous {
if sha256_prefixed(&on_disk) == record.content_hash {
return Ok(ProvisionedDestinationStatus::ManagedUpdate);
}
return Err(PrayError::Render(format!(
"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`"
)));
}
Err(PrayError::Render(format!(
"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"
)))
}
DestinationKind::Symlink => Err(symlink_error(path_text)),
DestinationKind::Other => Err(PrayError::Render(format!(
"refusing to write `{path_text}`; destination is not a regular file"
))),
}
}
fn prune_dropped_leaves(
project: &ResolvedProject,
previous: &Lockfile,
planned_paths: &BTreeSet<String>,
) -> PrayResult<()> {
for record in &previous.provisioned {
if planned_paths.contains(&record.path) {
continue;
}
let relative = validate_destination_path(&record.path)?;
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
let destination = relative.join_root(&project.project_root);
match destination_kind(&destination)? {
DestinationKind::Regular => {
let on_disk = read_regular_bytes(&destination, &record.path)?;
if sha256_prefixed(&on_disk) == record.content_hash {
ensure_safe_destination_ancestors(
&project.project_root,
relative.as_path(),
relative.as_str(),
)?;
if !crate::transaction::replace(&destination, Some(&on_disk), None)? {
fs::remove_file(&destination)?;
}
}
}
DestinationKind::Missing | DestinationKind::Symlink | DestinationKind::Other => {}
}
}
Ok(())
}
fn lock_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn update_regular_bytes(
path: &Path,
display: &str,
bytes: &[u8],
authorized_hash: &str,
) -> PrayResult<()> {
let mut file = open_regular(path, display, true)?;
let on_disk = read_destination_bytes(&mut file, display)?;
if on_disk == bytes {
return Ok(());
}
if sha256_prefixed(&on_disk) != authorized_hash {
return Err(PrayError::Render(format!(
"refusing to overwrite `{display}`; it was written by pray and then edited. Inspect your changes and move the file aside, then run `pray install`"
)));
}
if crate::transaction::replace(path, Some(&on_disk), Some(bytes))? {
return Ok(());
}
file.rewind()?;
file.set_len(0)?;
file.write_all(bytes)?;
Ok(())
}