weavatrix-worktree 0.2.1

Bounded, crash-recoverable multi-file worktree edits for source refactoring
Documentation
use std::io::{self, Read};

use cap_fs_ext::{DirExt, FollowSymlinks, MetadataExt, OpenOptionsFollowExt};
use cap_std::fs::{Dir, File, OpenOptions, Permissions};

use super::{FileIdentity, ParentIdentity, PortablePermissions, RESERVED_ROOTS, sync_directory};

mod metadata;

pub(super) use metadata::{
    identity, invalid, is_link_or_reparse, validate_artifact_name, validate_metadata,
};

pub(crate) struct TargetSnapshot {
    pub(crate) source: Vec<u8>,
    pub(crate) identity: FileIdentity,
    pub(crate) permissions: Permissions,
    pub(crate) portable_permissions: PortablePermissions,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct TargetProbe {
    pub(crate) bytes: u64,
    pub(crate) identity: FileIdentity,
    pub(crate) permissions: PortablePermissions,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SlotProbe {
    Absent,
    Present(TargetProbe),
}

pub(crate) enum SlotSnapshot {
    Absent,
    Present(TargetSnapshot),
}

pub(crate) struct TargetAccess {
    pub(super) parent: Dir,
    pub(super) name: String,
    pub(super) path: String,
    pub(super) root_device: u64,
    pub(super) parent_identity: ParentIdentity,
}

impl TargetAccess {
    pub(crate) fn open(root: &Dir, root_device: u64, path: &str) -> io::Result<Self> {
        weavatrix_refactor_plan::validate_plan_path(path, 4_096)
            .map_err(|error| invalid(error.to_string()))?;
        let portable_path = weavatrix_refactor_plan::portable_path_key(path);
        let first = portable_path
            .split('/')
            .next()
            .expect("validated path has a first segment");
        if RESERVED_ROOTS.contains(&first) {
            return Err(invalid("path is reserved for worktree transaction state"));
        }
        let reserved_identities = reserved_root_identities(root)?;
        let mut segments = path.split('/').peekable();
        let mut parent = root.try_clone()?;
        let mut name = None;
        while let Some(segment) = segments.next() {
            if segments.peek().is_none() {
                name = Some(segment.to_owned());
            } else {
                let metadata = parent.symlink_metadata(segment)?;
                if is_link_or_reparse(&metadata) {
                    return Err(invalid("target parent is a symbolic link or reparse point"));
                }
                parent = match parent.open_dir_nofollow(segment) {
                    Ok(dir) => dir,
                    Err(open_error) => {
                        if parent
                            .symlink_metadata(segment)
                            .is_ok_and(|metadata| is_link_or_reparse(&metadata))
                        {
                            return Err(invalid(
                                "target parent is a symbolic link or reparse point",
                            ));
                        }
                        return Err(open_error);
                    }
                };
                let metadata = parent.dir_metadata()?;
                if is_link_or_reparse(&metadata) {
                    return Err(invalid("target parent is a symbolic link or reparse point"));
                }
                if metadata.dev() != root_device {
                    return Err(invalid("target parent crosses the worktree filesystem"));
                }
                reject_reserved_identity(&metadata, &reserved_identities)?;
            }
        }
        let parent_metadata = parent.dir_metadata()?;
        if is_link_or_reparse(&parent_metadata) || parent_metadata.dev() != root_device {
            return Err(invalid("target parent identity is not safe"));
        }
        let name = name.ok_or_else(|| invalid("target path has no file name"))?;
        match parent.symlink_metadata(&name) {
            Ok(metadata) => reject_reserved_identity(&metadata, &reserved_identities)?,
            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
            Err(error) => return Err(error),
        }
        Ok(Self {
            parent,
            name,
            path: path.to_owned(),
            root_device,
            parent_identity: ParentIdentity {
                device: parent_metadata.dev(),
                inode: parent_metadata.ino(),
            },
        })
    }

    pub(crate) fn try_clone(&self) -> io::Result<Self> {
        Ok(Self {
            parent: self.parent.try_clone()?,
            name: self.name.clone(),
            path: self.path.clone(),
            root_device: self.root_device,
            parent_identity: self.parent_identity,
        })
    }

    pub(crate) fn snapshot(&self, max_bytes: usize) -> io::Result<TargetSnapshot> {
        let mut file = self.open_file()?;
        let metadata = file.metadata()?;
        validate_metadata(&metadata, self.root_device)?;
        let mut source = Vec::with_capacity(
            usize::try_from(metadata.len())
                .unwrap_or(max_bytes)
                .min(max_bytes),
        );
        let limit = u64::try_from(max_bytes)
            .unwrap_or(u64::MAX)
            .checked_add(1)
            .ok_or_else(|| invalid("source byte limit overflow"))?;
        file.by_ref().take(limit).read_to_end(&mut source)?;
        if source.len() > max_bytes {
            return Err(invalid("source exceeds the per-file byte limit"));
        }
        let permissions = metadata.permissions();
        Ok(TargetSnapshot {
            source,
            identity: identity(&metadata),
            portable_permissions: PortablePermissions::from_permissions(&permissions),
            permissions,
        })
    }

    pub(crate) fn probe(&self) -> io::Result<TargetProbe> {
        let file = self.open_file()?;
        let metadata = file.metadata()?;
        validate_metadata(&metadata, self.root_device)?;
        Ok(TargetProbe {
            bytes: metadata.len(),
            identity: identity(&metadata),
            permissions: PortablePermissions::from_permissions(&metadata.permissions()),
        })
    }

    pub(crate) fn create_new(&self, name: &str) -> io::Result<File> {
        validate_artifact_name(name)?;
        let mut options = OpenOptions::new();
        options.write(true).create_new(true);
        options.follow(FollowSymlinks::No);
        self.parent.open_with(name, &options)
    }

    pub(crate) fn open_artifact(&self, name: &str) -> io::Result<File> {
        validate_artifact_name(name)?;
        let mut options = OpenOptions::new();
        options.read(true);
        options.follow(FollowSymlinks::No);
        self.parent.open_with(name, &options)
    }

    pub(crate) fn read_artifact(&self, name: &str, max_bytes: usize) -> io::Result<Vec<u8>> {
        let mut file = self.open_artifact(name)?;
        let metadata = file.metadata()?;
        if !metadata.is_file() || metadata.dev() != self.root_device || metadata.nlink() != 1 {
            return Err(invalid("transaction artifact identity is not safe"));
        }
        let limit = u64::try_from(max_bytes)
            .unwrap_or(u64::MAX)
            .checked_add(1)
            .ok_or_else(|| invalid("artifact byte limit overflow"))?;
        let mut bytes = Vec::with_capacity(
            usize::try_from(metadata.len())
                .unwrap_or(max_bytes)
                .min(max_bytes),
        );
        file.by_ref().take(limit).read_to_end(&mut bytes)?;
        if bytes.len() > max_bytes {
            return Err(invalid("transaction artifact exceeds its byte limit"));
        }
        Ok(bytes)
    }

    pub(crate) fn rename_from(&self, artifact: &str) -> io::Result<()> {
        validate_artifact_name(artifact)?;
        self.parent.rename(artifact, &self.parent, &self.name)
    }

    pub(crate) fn remove_artifact(&self, name: &str) -> io::Result<bool> {
        validate_artifact_name(name)?;
        let metadata = match self.parent.symlink_metadata(name) {
            Ok(metadata) => metadata,
            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
            Err(error) => return Err(error),
        };
        if is_link_or_reparse(&metadata)
            || !metadata.is_file()
            || metadata.dev() != self.root_device
            || metadata.nlink() != 1
        {
            return Err(invalid("refusing to remove an unsafe transaction artifact"));
        }
        self.parent.remove_file(name)?;
        self.sync_parent()?;
        Ok(true)
    }

    pub(crate) fn sync_parent(&self) -> io::Result<()> {
        sync_directory(&self.parent)
    }

    pub(crate) fn path(&self) -> &str {
        &self.path
    }

    pub(crate) const fn parent_identity(&self) -> ParentIdentity {
        self.parent_identity
    }

    fn open_file(&self) -> io::Result<File> {
        let metadata = self.parent.symlink_metadata(&self.name)?;
        if is_link_or_reparse(&metadata) {
            return Err(invalid("target is a symbolic link or reparse point"));
        }
        validate_metadata(&metadata, self.root_device)?;
        let expected_identity = identity(&metadata);
        let mut options = OpenOptions::new();
        options.read(true);
        options.follow(FollowSymlinks::No);
        let file = self.parent.open_with(&self.name, &options)?;
        let opened_metadata = file.metadata()?;
        validate_metadata(&opened_metadata, self.root_device)?;
        if identity(&opened_metadata) != expected_identity {
            return Err(invalid("target identity changed while it was opened"));
        }
        Ok(file)
    }
}

fn reserved_root_identities(root: &Dir) -> io::Result<[Option<FileIdentity>; 2]> {
    let mut identities = [None, None];
    for (index, name) in RESERVED_ROOTS.into_iter().enumerate() {
        identities[index] = match root.symlink_metadata(name) {
            Ok(metadata) => Some(identity(&metadata)),
            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
            Err(error) => return Err(error),
        };
    }
    Ok(identities)
}

fn reject_reserved_identity(
    metadata: &cap_std::fs::Metadata,
    reserved: &[Option<FileIdentity>; 2],
) -> io::Result<()> {
    if reserved.contains(&Some(identity(metadata))) {
        Err(invalid("path resolves through a reserved worktree root"))
    } else {
        Ok(())
    }
}