pray-core 1.12.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
#[path = "recovery.rs"]
mod recovery;

use super::owner::{failure, private_file, sync_directory, token};
use crate::{hashing::sha256_prefixed, render_file::read_regular_bytes, PrayResult};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeSet,
    fs::{self, OpenOptions},
    io::{Read, Write},
    path::{Path, PathBuf},
};

const MAX_LOG: u64 = 96 * 1024 * 1024;
const MAX_SAVED: usize = 64 * 1024 * 1024;
const MAX_ENTRIES: usize = 10_000;

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Entry {
    path: String,
    before: Option<String>,
    after_hash: Option<String>,
    mode: u32,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum Record {
    Start { version: u32 },
    Write { entry: Entry },
    Undone { index: usize },
    Commit,
}

pub(super) struct Journal {
    pub root: PathBuf,
    directory: PathBuf,
    path: PathBuf,
    saved: usize,
    entries: usize,
}

impl Journal {
    pub fn open(root: PathBuf, directory: PathBuf) -> PrayResult<Self> {
        Ok(Self {
            root,
            path: directory.join("journal"),
            directory,
            saved: 0,
            entries: 0,
        })
    }

    fn append(&self, record: &Record) -> PrayResult<()> {
        let mut bytes = serde_json::to_vec(record).map_err(|error| failure(&error.to_string()))?;
        bytes.push(b'\n');
        let mut options = OpenOptions::new();
        options.append(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            options.custom_flags(libc::O_NOFOLLOW);
        }
        let mut file = options.open(&self.path)?;
        if !file.metadata()?.is_file() || file.metadata()?.len() + bytes.len() as u64 > MAX_LOG {
            return Err(failure("project recovery journal exceeds its 96 MiB limit"));
        }
        file.write_all(&bytes)?;
        file.sync_all()?;
        Ok(())
    }

    pub fn replace(
        &mut self,
        path: &Path,
        before: Option<&[u8]>,
        after: Option<&[u8]>,
    ) -> PrayResult<()> {
        if before == after {
            return Ok(());
        }
        let absolute = if path.is_absolute() {
            path.to_path_buf()
        } else {
            std::env::current_dir()?.join(path)
        };
        let relative = absolute
            .strip_prefix(&self.root)
            .map_err(|_| failure("destination is outside this project"))?;
        let display = relative.to_string_lossy().replace('\\', "/");
        self.destination(&display)?;
        if before
            .into_iter()
            .chain(after)
            .any(|bytes| bytes.len() > 32 * 1024 * 1024)
        {
            return Err(failure("destination exceeds the 32 MiB limit"));
        }
        if self.saved + before.map_or(0, <[u8]>::len) + after.map_or(0, <[u8]>::len) > MAX_SAVED
            || self.entries == MAX_ENTRIES
        {
            return Err(failure(
                "project write exceeds the 64 MiB or 10000-file recovery limit",
            ));
        }
        if snapshot(&absolute)?.as_deref() != before {
            return Err(changed(&display));
        }
        let mode = permissions(&absolute)?;
        if !self.path.try_exists()? {
            private_file(&self.path)?.sync_all()?;
            self.append(&Record::Start { version: 1 })?;
            sync_directory(&self.directory)?;
        }
        let entry = Entry {
            path: display.clone(),
            before: before.map(|bytes| STANDARD.encode(bytes)),
            after_hash: after.map(sha256_prefixed),
            mode,
        };
        self.append(&Record::Write { entry })?;
        self.saved += before.map_or(0, <[u8]>::len) + after.map_or(0, <[u8]>::len);
        self.entries += 1;
        self.install(&display, before, after, mode)
    }

    fn destination(&self, display: &str) -> PrayResult<PathBuf> {
        let relative = crate::paths::validate_destination_path(display)?;
        if relative.as_path().starts_with(".pray/write-state") {
            return Err(failure("destination overlaps project recovery state"));
        }
        crate::render_path_guard::ensure_safe_destination_ancestors(
            &self.root,
            relative.as_path(),
            display,
        )?;
        Ok(relative.join_root(&self.root))
    }

    fn install(
        &self,
        display: &str,
        expected: Option<&[u8]>,
        bytes: Option<&[u8]>,
        mode: u32,
    ) -> PrayResult<()> {
        let path = self.destination(display)?;
        let parent = path.parent().unwrap();
        fs::create_dir_all(parent)?;
        self.destination(display)?;
        let temporary = self.directory.join(format!("{}.stage", token()?));
        if let Some(bytes) = bytes {
            let mut file = private_file(&temporary)?;
            file.write_all(bytes)?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?;
            }
            file.sync_all()?;
        }
        if snapshot(&path)?.as_deref() != expected {
            return Err(changed(display));
        }
        self.destination(display)?;
        match (expected, bytes) {
            (None, Some(_)) => {
                fs::hard_link(&temporary, &path)?;
                fs::remove_file(&temporary)?;
            }
            (Some(_), Some(_)) => fs::rename(&temporary, &path)?,
            (Some(_), None) => fs::remove_file(&path)?,
            (None, None) => {}
        }
        self.sync_parents(&path)
    }

    fn sync_parents(&self, path: &Path) -> PrayResult<()> {
        let mut current = path.parent().unwrap();
        loop {
            match sync_directory(current) {
                Err(crate::PrayError::Io(error))
                    if error.kind() == std::io::ErrorKind::NotFound => {}
                result => result?,
            }
            if current == self.root {
                break;
            }
            current = current
                .parent()
                .ok_or_else(|| failure("destination is outside this project"))?;
        }
        Ok(())
    }

    pub fn commit(&self) -> PrayResult<()> {
        if self.path.try_exists()? {
            self.append(&Record::Commit)?;
            fs::remove_file(&self.path)?;
            sync_directory(&self.directory)?;
        }
        self.clean_stages()
    }

    fn clean_stages(&self) -> PrayResult<()> {
        for entry in fs::read_dir(&self.directory)? {
            let entry = entry?;
            let name = entry.file_name().to_string_lossy().into_owned();
            if name.len() == 38
                && name.ends_with(".stage")
                && name[..32].bytes().all(|byte| byte.is_ascii_hexdigit())
            {
                fs::remove_file(entry.path())?;
            }
        }
        Ok(())
    }
}

fn snapshot(path: &Path) -> PrayResult<Option<Vec<u8>>> {
    match crate::render_file::destination_kind(path)? {
        crate::render_file::DestinationKind::Missing => Ok(None),
        _ => Ok(Some(read_regular_bytes(path, &path.display().to_string())?)),
    }
}
fn permissions(path: &Path) -> PrayResult<u32> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(metadata) = fs::symlink_metadata(path) {
            return Ok(metadata.permissions().mode() & 0o777);
        }
    }
    Ok(0o644)
}
fn changed(display: &str) -> crate::PrayError {
    failure(&format!("`{display}` changed during the interrupted write. Inspect your changes and move the file aside, then retry; recovery data remains in .pray/write-state"))
}