provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::{OutputState, StageIdentity, TransactionDirectory};
use crate::wiki::publish::{CleanupWarning, PublishError};
use camino::Utf8Path;
use std::fs::File;

pub(in crate::wiki::publish) fn replace_output(
    output: &Utf8Path,
    policy: super::super::OutputPolicy,
    transaction: &TransactionDirectory,
    output_state: OutputState,
    stage_identity: &StageIdentity,
) -> Result<Vec<CleanupWarning>, PublishError> {
    replace_output_with_validation(
        output,
        transaction,
        output_state,
        stage_identity,
        |_, _| Ok(()),
        |_| Ok(()),
        |backup| transaction.validate_output(&transaction.leaves.backup, backup, policy),
    )
}

#[cfg(test)]
pub(in crate::wiki::publish) fn replace_output_with(
    output: &Utf8Path,
    paths: &super::TransactionPaths,
    before_rename: impl FnMut(&Utf8Path, &Utf8Path) -> std::io::Result<()>,
    before_cleanup: impl FnMut(&Utf8Path) -> std::io::Result<()>,
) -> Result<Vec<CleanupWarning>, PublishError> {
    let transaction = TransactionDirectory::open(output)?;
    let output_state = super::ownership::output_identity(output)?;
    // A plain File::open on a directory fails ERROR_ACCESS_DENIED on Windows
    // (no backup semantics); the no-follow directory open is also the right
    // semantics for a transaction-owned stage.
    let stage =
        super::ownership::open_directory_no_follow(paths.stage.as_std_path()).map_err(|error| {
            PublishError::io("open staging directory identity", &paths.stage, error)
        })?;
    let stage_identity = StageIdentity::from_file(&stage).map_err(|error| {
        PublishError::io("record staging directory identity", &paths.stage, error)
    })?;
    replace_output_with_validation(
        output,
        &transaction,
        output_state,
        &stage_identity,
        before_rename,
        before_cleanup,
        |_| Ok(()),
    )
}

fn replace_output_with_validation(
    output: &Utf8Path,
    transaction: &TransactionDirectory,
    output_state: OutputState,
    stage_identity: &StageIdentity,
    mut before_rename: impl FnMut(&Utf8Path, &Utf8Path) -> std::io::Result<()>,
    mut before_cleanup: impl FnMut(&Utf8Path) -> std::io::Result<()>,
    validate_backup: impl FnOnce(&Utf8Path) -> Result<(), PublishError>,
) -> Result<Vec<CleanupWarning>, PublishError> {
    let paths = &transaction.paths;
    transaction.verify_requested_parent(output)?;
    verify_stage_identity(
        stage_identity,
        transaction,
        &transaction.leaves.stage,
        &paths.stage,
        "staging directory was replaced during generation",
    )?;
    let mut rename = |from: &Utf8Path, to: &Utf8Path| {
        before_rename(from, to)?;
        let from_leaf = from.file_name().expect("transaction path has leaf");
        let to_leaf = to.file_name().expect("transaction path has leaf");
        transaction.rename(from_leaf, to_leaf)
    };
    if let OutputState::Existing(expected_identity) = output_state {
        rename(output, &paths.backup)
            .map_err(|error| PublishError::io("move previous output to backup", output, error))?;
        let validation = validate_backup(&paths.backup).and_then(|()| {
            match transaction.output_identity(&transaction.leaves.backup, &paths.backup)? {
                OutputState::Existing(actual_identity)
                    if actual_identity.0 == expected_identity.0 =>
                {
                    Ok(())
                }
                _ => Err(PublishError::OutputChanged {
                    path: output.to_path_buf(),
                    detail: "filesystem identity no longer matches the preflight output"
                        .to_string(),
                }),
            }
        });
        if let Err(validation) = validation {
            return Err(super::cleanup::rollback_validation_failure(
                output,
                paths,
                &validation,
                &mut rename,
            ));
        }
        if let Err(install) = rename(&paths.stage, output) {
            return Err(super::cleanup::rollback_install_failure(
                output,
                paths,
                install,
                &mut rename,
            ));
        }
        super::cleanup::verify_installed_stage_with_backup(
            stage_identity,
            output,
            transaction,
            &mut rename,
        )?;
        let cleanup = super::cleanup::cleanup_backup(transaction, &mut before_cleanup);
        if let Err(error) = cleanup {
            return Ok(vec![CleanupWarning {
                path: paths.backup.clone(),
                action: "remove previous output backup",
                error: error.to_string(),
            }]);
        }
    } else {
        rename(&paths.stage, output).map_err(|error| {
            if error.kind() == std::io::ErrorKind::AlreadyExists {
                PublishError::OutputChanged {
                    path: output.to_path_buf(),
                    detail: "output appeared after preflight".to_string(),
                }
            } else {
                PublishError::io("install completed wiki", output, error)
            }
        })?;
        if let Err(validation) = verify_stage_identity(
            stage_identity,
            transaction,
            &transaction.output_leaf,
            output,
            "installed output does not match the generated staging directory",
        ) {
            rename(output, &paths.stage).map_err(|error| {
                PublishError::io("quarantine replaced staging directory", output, error)
            })?;
            return Err(validation);
        }
    }
    Ok(Vec::new())
}

pub(super) fn verify_stage_identity(
    expected: &StageIdentity,
    transaction: &TransactionDirectory,
    leaf: &str,
    path: &Utf8Path,
    detail: &'static str,
) -> Result<(), PublishError> {
    if transaction
        .child_identity(leaf)
        .map_err(|error| PublishError::io("verify staging directory identity", path, error))?
        == expected.0
    {
        Ok(())
    } else {
        Err(PublishError::OutputChanged {
            path: path.to_path_buf(),
            detail: detail.to_string(),
        })
    }
}

#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
pub(super) fn rename_no_replace_at(
    parent: &File,
    _parent_path: &Utf8Path,
    from: &str,
    to: &str,
) -> std::io::Result<()> {
    rustix::fs::renameat_with(parent, from, parent, to, rustix::fs::RenameFlags::NOREPLACE)
        .map_err(std::io::Error::from)
}

#[cfg(target_os = "macos")]
pub(super) fn rename_no_replace_at(
    parent: &File,
    _parent_path: &Utf8Path,
    from: &str,
    to: &str,
) -> std::io::Result<()> {
    use std::ffi::CString;
    use std::os::raw::{c_char, c_int, c_uint};
    use std::os::unix::ffi::OsStrExt;

    extern "C" {
        fn renameatx_np(
            from_dir: c_int,
            from: *const c_char,
            to_dir: c_int,
            to: *const c_char,
            flags: c_uint,
        ) -> c_int;
    }

    use std::os::fd::AsRawFd;
    let from = CString::new(from.as_bytes())
        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
    let to = CString::new(to.as_bytes())
        .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
    // Darwin's renameatx_np with RENAME_EXCL atomically refuses an existing destination.
    let result = unsafe {
        renameatx_np(
            parent.as_raw_fd(),
            from.as_ptr(),
            parent.as_raw_fd(),
            to.as_ptr(),
            0x0000_0004,
        )
    };
    if result == 0 {
        Ok(())
    } else {
        Err(std::io::Error::last_os_error())
    }
}

#[cfg(windows)]
pub(super) fn rename_no_replace_at(
    parent: &File,
    parent_path: &Utf8Path,
    from: &str,
    to: &str,
) -> std::io::Result<()> {
    use fs_at::os::windows::OpenOptionsExt;
    use std::ffi::OsStr;
    use std::mem::size_of;
    use std::os::windows::{ffi::OsStrExt, io::AsRawHandle};
    use windows_sys::Win32::Foundation::HANDLE;
    use windows_sys::Win32::Storage::FileSystem::{
        FileRenameInfoEx, SetFileInformationByHandle, FILE_RENAME_INFO, FILE_RENAME_INFO_0,
    };

    const DELETE_ACCESS: u32 = 0x0001_0000;
    let mut options = fs_at::OpenOptions::default();
    options.desired_access(DELETE_ACCESS).follow(false);
    let source = options.open_path_at(parent, from)?;
    // SetFileInformationByHandle refuses a non-null RootDirectory with
    // ERROR_INVALID_PARAMETER; the destination must be a full path.
    let destination = parent_path.join(to);
    let name: Vec<u16> = OsStr::new(destination.as_std_path())
        .encode_wide()
        .collect();
    let name_bytes = name
        .len()
        .checked_mul(size_of::<u16>())
        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
    let buffer_size = size_of::<FILE_RENAME_INFO>()
        .checked_add(name_bytes)
        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
    let mut storage = vec![0_usize; buffer_size.div_ceil(size_of::<usize>())];
    let info = storage.as_mut_ptr().cast::<FILE_RENAME_INFO>();
    unsafe {
        info.write(FILE_RENAME_INFO {
            Anonymous: FILE_RENAME_INFO_0 { Flags: 0 },
            RootDirectory: 0 as HANDLE,
            FileNameLength: u32::try_from(name_bytes)
                .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?,
            FileName: [0],
        });
        std::ptr::copy_nonoverlapping(
            name.as_ptr(),
            std::ptr::addr_of_mut!((*info).FileName).cast::<u16>(),
            name.len(),
        );
        if SetFileInformationByHandle(
            source.as_raw_handle() as HANDLE,
            FileRenameInfoEx,
            info.cast(),
            u32::try_from(buffer_size)
                .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?,
        ) == 0
        {
            return Err(std::io::Error::last_os_error());
        }
    }
    Ok(())
}

#[cfg(not(any(
    target_os = "linux",
    target_os = "android",
    target_os = "freebsd",
    target_os = "macos",
    windows
)))]
pub(super) fn rename_no_replace_at(_parent: &File, _from: &str, _to: &str) -> std::io::Result<()> {
    Err(std::io::Error::new(
        std::io::ErrorKind::Unsupported,
        "atomic no-replace rename is unavailable on this platform",
    ))
}