Merge remote-tracking branch 'backup/open'
This commit is contained in:
commit
7e2bdbe6df
10 changed files with 563 additions and 117 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -1515,6 +1515,7 @@ dependencies = [
|
|||
"chardetng",
|
||||
"clipboard-win",
|
||||
"crossterm",
|
||||
"filetime",
|
||||
"futures-util",
|
||||
"helix-core",
|
||||
"helix-dap",
|
||||
|
@ -1529,6 +1530,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slotmap",
|
||||
|
|
|
@ -132,6 +132,21 @@ pub fn cache_dir() -> PathBuf {
|
|||
path
|
||||
}
|
||||
|
||||
pub fn state_dir() -> PathBuf {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let strategy = choose_base_strategy().expect("Unable to find the state directory!");
|
||||
let mut path = strategy.state_dir().unwrap();
|
||||
path.push("helix");
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cache_dir()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
CONFIG_FILE.get().map(|path| path.to_path_buf()).unwrap()
|
||||
}
|
||||
|
|
|
@ -50,13 +50,6 @@ mod imp {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn chown(p: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
|
||||
let uid = uid.map(|n| unsafe { rustix::fs::Uid::from_raw(n) });
|
||||
let gid = gid.map(|n| unsafe { rustix::fs::Gid::from_raw(n) });
|
||||
rustix::fs::chown(p, uid, gid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> {
|
||||
let from_meta = std::fs::metadata(from)?;
|
||||
let to_meta = std::fs::metadata(to)?;
|
||||
|
@ -81,13 +74,14 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess except for `chown`, `copy_metadata` and `is_acl_inherited`
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE};
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, LocalFree, ERROR_SUCCESS, GENERIC_READ, GENERIC_WRITE, HANDLE,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
GetNamedSecurityInfoW, SetNamedSecurityInfoW, SE_FILE_OBJECT,
|
||||
GetNamedSecurityInfoW, SetSecurityInfo, SE_FILE_OBJECT,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
AccessCheck, AclSizeInformation, GetAce, GetAclInformation, GetSidIdentifierAuthority,
|
||||
|
@ -100,7 +94,8 @@ mod imp {
|
|||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ACCESS_RIGHTS,
|
||||
FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
|
||||
FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, WRITE_DAC,
|
||||
WRITE_OWNER,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentThread, OpenThreadToken};
|
||||
|
||||
|
@ -108,8 +103,10 @@ mod imp {
|
|||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle};
|
||||
|
||||
// Licensed under MIT from faccess
|
||||
struct SecurityDescriptor {
|
||||
sd: PSECURITY_DESCRIPTOR,
|
||||
owner: PSID,
|
||||
|
@ -128,6 +125,7 @@ mod imp {
|
|||
}
|
||||
|
||||
impl SecurityDescriptor {
|
||||
// Licensed under MIT from faccess
|
||||
fn for_path(p: &Path) -> io::Result<SecurityDescriptor> {
|
||||
let path = std::fs::canonicalize(p)?;
|
||||
let pathos = path.into_os_string();
|
||||
|
@ -202,6 +200,7 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess
|
||||
struct ThreadToken(HANDLE);
|
||||
impl Drop for ThreadToken {
|
||||
fn drop(&mut self) {
|
||||
|
@ -211,6 +210,7 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess
|
||||
impl ThreadToken {
|
||||
fn new() -> io::Result<Self> {
|
||||
unsafe {
|
||||
|
@ -237,6 +237,7 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess
|
||||
// Based roughly on Tcl's NativeAccess()
|
||||
// https://github.com/tcltk/tcl/blob/2ee77587e4dc2150deb06b48f69db948b4ab0584/win/tclWinFile.c
|
||||
fn eaccess(p: &Path, mut mode: FILE_ACCESS_RIGHTS) -> io::Result<()> {
|
||||
|
@ -330,6 +331,7 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess
|
||||
pub fn access(p: &Path, mode: AccessMode) -> io::Result<()> {
|
||||
let mut imode = 0;
|
||||
|
||||
|
@ -356,13 +358,8 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
fn chown(p: &Path, sd: SecurityDescriptor) -> io::Result<()> {
|
||||
let path = std::fs::canonicalize(p)?;
|
||||
let pathos = path.as_os_str();
|
||||
let mut pathw = Vec::with_capacity(pathos.len() + 1);
|
||||
pathw.extend(pathos.encode_wide());
|
||||
pathw.push(0);
|
||||
|
||||
// SAFETY: It is the caller's responsibility to close the handle
|
||||
fn chown(handle: HANDLE, sd: SecurityDescriptor) -> io::Result<()> {
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut group = std::ptr::null_mut();
|
||||
let mut dacl = std::ptr::null();
|
||||
|
@ -387,8 +384,8 @@ mod imp {
|
|||
}
|
||||
|
||||
let err = unsafe {
|
||||
SetNamedSecurityInfoW(
|
||||
pathw.as_ptr(),
|
||||
SetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
si,
|
||||
owner,
|
||||
|
@ -405,9 +402,18 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> {
|
||||
pub fn copy_ownership(from: &Path, to: &Path) -> io::Result<()> {
|
||||
let sd = SecurityDescriptor::for_path(from)?;
|
||||
chown(to, sd)?;
|
||||
let to_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.access_mode(GENERIC_READ | GENERIC_WRITE | WRITE_OWNER | WRITE_DAC)
|
||||
.open(to)?;
|
||||
chown(to_file.as_raw_handle(), sd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> {
|
||||
copy_ownership(from, to)?;
|
||||
|
||||
let meta = std::fs::metadata(from)?;
|
||||
let perms = meta.permissions();
|
||||
|
@ -417,17 +423,22 @@ mod imp {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hardlink_count(p: &Path) -> std::io::Result<u64> {
|
||||
let file = std::fs::File::open(p)?;
|
||||
fn file_info(p: &Path) -> io::Result<BY_HANDLE_FILE_INFORMATION> {
|
||||
let file = File::open(p)?;
|
||||
let handle = file.as_raw_handle();
|
||||
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
|
||||
if unsafe { GetFileInformationByHandle(handle, &mut info) } == 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(info.nNumberOfLinks as u64)
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hardlink_count(p: &Path) -> io::Result<u64> {
|
||||
let n = file_info(p)?.nNumberOfLinks as u64;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
// Licensed under MIT from faccess except for `copy_metadata`
|
||||
|
@ -453,14 +464,6 @@ mod imp {
|
|||
Err(io::Error::new(io::ErrorKind::NotFound, "Path not found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_metadata(from: &path, to: &Path) -> io::Result<()> {
|
||||
let meta = std::fs::metadata(from)?;
|
||||
let perms = meta.permissions();
|
||||
std::fs::set_permissions(to, perms)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readonly(p: &Path) -> bool {
|
||||
|
@ -471,10 +474,102 @@ pub fn readonly(p: &Path) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn hardlink_count(p: &Path) -> io::Result<u64> {
|
||||
imp::hardlink_count(p)
|
||||
}
|
||||
|
||||
pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> {
|
||||
imp::copy_metadata(from, to)
|
||||
}
|
||||
|
||||
pub fn hardlink_count(p: &Path) -> io::Result<u64> {
|
||||
imp::hardlink_count(p)
|
||||
#[cfg(windows)]
|
||||
pub fn copy_ownership(from: &Path, to: &Path) -> io::Result<()> {
|
||||
imp::copy_ownership(from, to)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn chown(p: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
|
||||
let uid = uid.map(|n| unsafe { rustix::fs::Uid::from_raw(n) });
|
||||
let gid = gid.map(|n| unsafe { rustix::fs::Gid::from_raw(n) });
|
||||
rustix::fs::chown(p, uid, gid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn fchown(fd: impl std::os::fd::AsFd, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
|
||||
let uid = uid.map(|n| unsafe { rustix::fs::Uid::from_raw(n) });
|
||||
let gid = gid.map(|n| unsafe { rustix::fs::Gid::from_raw(n) });
|
||||
rustix::fs::fchown(fd, uid, gid)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn copy_xattr(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
use std::ffi::CStr;
|
||||
|
||||
if !src.exists() || !dst.exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"src or dst file was not found while copying attributes",
|
||||
));
|
||||
}
|
||||
|
||||
let size = match rustix::fs::listxattr(src, &mut [])? {
|
||||
0 => return Ok(()), // No attributes
|
||||
len => len,
|
||||
};
|
||||
|
||||
let mut key_list = vec![0; size];
|
||||
let size = rustix::fs::listxattr(src, key_list.as_mut_slice())?;
|
||||
if key_list.len() != size {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"`{}`'s xattr list changed while copying attributes",
|
||||
src.to_string_lossy()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Iterate over null-terminated C-style strings
|
||||
// Two loops to avoid multiple allocations
|
||||
// Find max-size for attributes
|
||||
let mut max_val_len = 0;
|
||||
for key in key_list[..size].split_inclusive(|&b| b == 0) {
|
||||
// Needed on macos
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let conv = unsafe { std::slice::from_raw_parts(key.as_ptr() as *const u8, key.len()) };
|
||||
let key = CStr::from_bytes_with_nul(conv)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let attr_len = rustix::fs::getxattr(src, key, &mut [])?;
|
||||
max_val_len = max_val_len.max(attr_len);
|
||||
}
|
||||
|
||||
let mut attr_buf = vec![0u8; max_val_len];
|
||||
for key in key_list[..size].split_inclusive(|&b| b == 0) {
|
||||
// Needed on macos
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let conv = unsafe { std::slice::from_raw_parts(key.as_ptr() as *const u8, key.len()) };
|
||||
let key = CStr::from_bytes_with_nul(conv)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let read = rustix::fs::getxattr(src, key, attr_buf.as_mut_slice())?;
|
||||
|
||||
// If we can't set xattr because it already exists, try to replace it
|
||||
if read != 0 {
|
||||
match rustix::fs::setxattr(dst, key, &attr_buf[..read], rustix::fs::XattrFlags::CREATE)
|
||||
{
|
||||
Err(rustix::io::Errno::EXIST) => rustix::fs::setxattr(
|
||||
dst,
|
||||
key,
|
||||
&attr_buf[..read],
|
||||
rustix::fs::XattrFlags::REPLACE,
|
||||
)?,
|
||||
Err(e) => return Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ use std::{
|
|||
ffi::OsString,
|
||||
ops::Range,
|
||||
path::{Component, Path, PathBuf, MAIN_SEPARATOR_STR},
|
||||
str::Utf8Error,
|
||||
};
|
||||
|
||||
use crate::env::current_working_dir;
|
||||
|
@ -295,6 +296,60 @@ pub fn expand<T: AsRef<Path> + ?Sized>(path: &T) -> Cow<'_, Path> {
|
|||
}
|
||||
}
|
||||
|
||||
fn os_str_as_bytes<P: AsRef<std::ffi::OsStr>>(path: P) -> Vec<u8> {
|
||||
let path = path.as_ref();
|
||||
|
||||
#[cfg(windows)]
|
||||
return path.to_str().unwrap().into();
|
||||
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::ffi::OsStrExt::as_bytes(path).to_vec();
|
||||
}
|
||||
|
||||
fn path_from_bytes(slice: &[u8]) -> Result<PathBuf, Utf8Error> {
|
||||
#[cfg(windows)]
|
||||
let res = PathBuf::from(std::str::from_utf8(slice)?);
|
||||
|
||||
#[cfg(unix)]
|
||||
let res = PathBuf::from(<std::ffi::OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(slice));
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn is_sep_byte(b: u8) -> bool {
|
||||
if cfg!(windows) {
|
||||
b == b'/' || b == b'\\' || b == b':'
|
||||
} else {
|
||||
b == b'/'
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces all path separators in a path with %
|
||||
pub fn escape_path(path: &Path) -> PathBuf {
|
||||
let s = path.as_os_str().to_os_string();
|
||||
let mut bytes = os_str_as_bytes(s);
|
||||
for b in bytes.iter_mut() {
|
||||
if is_sep_byte(*b) {
|
||||
*b = b'%';
|
||||
}
|
||||
}
|
||||
path_from_bytes(&bytes).unwrap()
|
||||
}
|
||||
|
||||
pub fn add_extension<S: AsRef<std::ffi::OsStr>>(p: &Path, extension: S) -> Cow<'_, Path> {
|
||||
let new = extension.as_ref();
|
||||
if new.is_empty() {
|
||||
Cow::Borrowed(p)
|
||||
} else {
|
||||
let Some(mut ext) = p.extension().map(std::ffi::OsStr::to_owned) else {
|
||||
return Cow::Borrowed(p);
|
||||
};
|
||||
ext.push(".");
|
||||
ext.push(new);
|
||||
Cow::Owned(p.with_extension(ext))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
#![cfg(windows)]
|
||||
|
||||
use std::{
|
||||
env::set_current_dir,
|
||||
error::Error,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
use std::{env::set_current_dir, error::Error, path::Component};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use helix_stdx::path;
|
||||
use tempfile::Builder;
|
||||
|
|
|
@ -239,6 +239,14 @@ impl Application {
|
|||
.unwrap_or_else(|_| editor.new_file(Action::VerticalSplit));
|
||||
}
|
||||
|
||||
let bck_config = &config.load().editor.backup;
|
||||
if bck_config.kind != helix_view::editor::BackupKind::None
|
||||
&& !bck_config.directories.iter().any(|p| p.exists())
|
||||
{
|
||||
// Initialize last directory for backups
|
||||
std::fs::create_dir_all(bck_config.directories.last().unwrap())?;
|
||||
}
|
||||
|
||||
editor.set_theme(theme);
|
||||
|
||||
#[cfg(windows)]
|
||||
|
|
|
@ -539,6 +539,7 @@ async fn test_symlink_write() -> anyhow::Result<()> {
|
|||
let dir = tempfile::tempdir()?;
|
||||
|
||||
let mut file = tempfile::NamedTempFile::new_in(&dir)?;
|
||||
// NOTE: This will fail on Windows unless ran in administrator
|
||||
let symlink_path = dir.path().join("linked");
|
||||
symlink(file.path(), &symlink_path)?;
|
||||
|
||||
|
@ -578,6 +579,7 @@ async fn test_symlink_write_fail() -> anyhow::Result<()> {
|
|||
|
||||
let file = helpers::new_readonly_tempfile_in_dir(&dir)?;
|
||||
let symlink_path = dir.path().join("linked");
|
||||
// NOTE: This will fail on Windows unless ran in administrator
|
||||
symlink(file.path(), &symlink_path)?;
|
||||
|
||||
let mut app = helpers::AppBuilder::new()
|
||||
|
@ -622,6 +624,7 @@ async fn test_symlink_write_relative() -> anyhow::Result<()> {
|
|||
let mut file = tempfile::NamedTempFile::new_in(&inner_dir)?;
|
||||
let symlink_path = dir.path().join("linked");
|
||||
let relative_path = std::path::PathBuf::from("b").join(file.path().file_name().unwrap());
|
||||
// NOTE: This will fail on Windows unless ran in administrator
|
||||
symlink(relative_path, &symlink_path)?;
|
||||
|
||||
let mut app = helpers::AppBuilder::new()
|
||||
|
@ -684,6 +687,36 @@ async fn test_hardlink_write() -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn test_write_ownership() -> anyhow::Result<()> {
|
||||
// GH CI does not possess CAP_CHOWN
|
||||
if option_env!("GITHUB_ACTIONS").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let mut file = tempfile::NamedTempFile::new()?;
|
||||
let mut app = helpers::AppBuilder::new()
|
||||
.with_file(file.path(), None)
|
||||
.build()?;
|
||||
|
||||
let nobody_uid = 9999;
|
||||
let nogroup_gid = 9999;
|
||||
|
||||
helix_stdx::faccess::fchown(&file.as_file_mut(), Some(nobody_uid), Some(nogroup_gid))?;
|
||||
|
||||
let old_meta = file.as_file().metadata()?;
|
||||
|
||||
test_key_sequence(&mut app, Some("hello:w<ret>"), None, false).await?;
|
||||
reload_file(&mut file).unwrap();
|
||||
|
||||
let new_meta = file.as_file().metadata()?;
|
||||
assert!(old_meta.uid() == new_meta.uid() && old_meta.gid() == new_meta.gid());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
|
||||
let mut file = tempfile::NamedTempFile::new()?;
|
||||
|
||||
|
|
|
@ -29,6 +29,8 @@ anyhow = "1"
|
|||
crossterm = { version = "0.28", optional = true }
|
||||
|
||||
tempfile = "3.14"
|
||||
same-file = "1.0.1"
|
||||
filetime = "0.2"
|
||||
|
||||
# Conversion traits
|
||||
once_cell = "1.20"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use anyhow::{anyhow, bail, Error};
|
||||
use arc_swap::access::DynAccess;
|
||||
use arc_swap::ArcSwap;
|
||||
use filetime::FileTime;
|
||||
use futures_util::future::BoxFuture;
|
||||
use futures_util::FutureExt;
|
||||
use helix_core::auto_pairs::AutoPairs;
|
||||
|
@ -11,7 +12,7 @@ use helix_core::snippets::{ActiveSnippet, SnippetRenderCtx};
|
|||
use helix_core::syntax::{Highlight, LanguageServerFeature};
|
||||
use helix_core::text_annotations::{InlineAnnotation, Overlay};
|
||||
use helix_lsp::util::lsp_pos_to_pos;
|
||||
use helix_stdx::faccess::{copy_metadata, readonly};
|
||||
use helix_stdx::faccess::readonly;
|
||||
use helix_vcs::{DiffHandle, DiffProviderRegistry};
|
||||
use thiserror;
|
||||
|
||||
|
@ -39,6 +40,7 @@ use helix_core::{
|
|||
};
|
||||
|
||||
use crate::theme::Color;
|
||||
use crate::editor::BackupConfig;
|
||||
use crate::{
|
||||
editor::Config,
|
||||
events::{DocumentDidChange, SelectionDidChange},
|
||||
|
@ -132,6 +134,154 @@ pub enum DocumentOpenError {
|
|||
IoError(#[from] io::Error),
|
||||
}
|
||||
|
||||
struct Backup {
|
||||
copy: bool,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Backup {
|
||||
async fn from(p: PathBuf, config: &BackupConfig) -> Result<Self, Error> {
|
||||
use crate::editor::BackupKind;
|
||||
use helix_stdx::faccess::*;
|
||||
|
||||
// This fn won't be called if it's none or it doesn't exist
|
||||
if config.kind == BackupKind::None || !p.exists() {
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
let mut copy = config.kind == BackupKind::Copy;
|
||||
|
||||
// Do not rename iff:
|
||||
// - it is a hardlink
|
||||
// - it is a symlink
|
||||
// - we don't have file create perms for the dir
|
||||
if !copy {
|
||||
// Conservatively assume it is a hardlink if we can't read metadata
|
||||
let is_hardlink = {
|
||||
let p_ = p.clone();
|
||||
tokio::task::spawn_blocking(move || hardlink_count(&p_).unwrap_or(2)).await? > 1
|
||||
};
|
||||
let is_symlink = tokio::fs::symlink_metadata(&p).await?.is_symlink();
|
||||
if is_hardlink || is_symlink {
|
||||
copy = true;
|
||||
} else {
|
||||
// Check if we have write permissions by creating a temporary file
|
||||
let from_meta = tokio::fs::metadata(&p).await?;
|
||||
let perms = from_meta.permissions();
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.permissions(perms);
|
||||
if let Ok(f) = builder.tempfile() {
|
||||
// Check if we have perms to set perms
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::{fd::AsFd, unix::fs::MetadataExt};
|
||||
|
||||
let to_meta = tokio::fs::metadata(&f.path()).await?;
|
||||
let _ = fchown(
|
||||
f.as_file().as_fd(),
|
||||
Some(from_meta.uid()),
|
||||
Some(from_meta.gid()),
|
||||
);
|
||||
|
||||
if from_meta.uid() != to_meta.uid()
|
||||
|| from_meta.gid() != to_meta.gid()
|
||||
|| from_meta.permissions() != to_meta.permissions()
|
||||
{
|
||||
copy = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
if copy_metadata(&p, f.path()).is_err() {
|
||||
copy = true;
|
||||
}
|
||||
} else {
|
||||
copy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for valid backup directory
|
||||
// Check if:
|
||||
// - directory is not writable
|
||||
// - path is a directory
|
||||
// - path exists
|
||||
let escaped_p = helix_stdx::path::escape_path(&p);
|
||||
// `.join` on absolute path replaces instead of append
|
||||
debug_assert!(escaped_p.is_relative());
|
||||
|
||||
'outer: for dir in config.directories.iter().filter(|p| p.is_dir()) {
|
||||
let ext = config.extension.as_str();
|
||||
let bck_base_path = &dir.join(&escaped_p);
|
||||
|
||||
// NOTE: `escaped_p` will make dot files appear to be extensions, so we need to append
|
||||
let mut backup = helix_stdx::path::add_extension(bck_base_path, ext).into_owned();
|
||||
|
||||
// NOTE: Should we just overwrite regardless?
|
||||
// If the backup file already exists, we'll try to add a number before the extension
|
||||
// until we're done
|
||||
// NOTE: u8 since if we need more than 256, there might be an issue
|
||||
let mut n: u8 = 1;
|
||||
while backup.exists() {
|
||||
backup = helix_stdx::path::add_extension(bck_base_path, format!("{n}.{ext}"))
|
||||
.into_owned();
|
||||
if n == u8::MAX {
|
||||
continue 'outer;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if copy {
|
||||
// Create the copy backup
|
||||
// TODO: How handle error?
|
||||
tokio::fs::copy(&p, &backup).await?;
|
||||
|
||||
let from_meta = tokio::fs::metadata(&p).await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let mut perms = from_meta.permissions();
|
||||
|
||||
// Strip s-bit
|
||||
perms.set_mode(perms.mode() & 0o0777);
|
||||
|
||||
let to_meta = tokio::fs::metadata(&backup).await?;
|
||||
let from_gid = from_meta.gid();
|
||||
let to_gid = to_meta.gid();
|
||||
|
||||
// If chown fails, se the protection bits for the roup the same as the perm bits for others
|
||||
if from_gid != to_gid && chown(&backup, None, Some(from_gid)).is_err() {
|
||||
let new_perms = (perms.mode() & 0o0707) | ((perms.mode() & 0o07) << 3);
|
||||
perms.set_mode(new_perms);
|
||||
}
|
||||
std::fs::set_permissions(&backup, perms)?;
|
||||
|
||||
copy_xattr(&p, &backup)?;
|
||||
}
|
||||
let atime = FileTime::from_last_access_time(&from_meta);
|
||||
let mtime = FileTime::from_last_modification_time(&from_meta);
|
||||
filetime::set_file_times(&backup, atime, mtime)?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let backup_ = backup.clone();
|
||||
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
|
||||
copy_ownership(&p, &backup_)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
} else {
|
||||
tokio::fs::rename(p, &backup).await?;
|
||||
}
|
||||
return Ok(Self { copy, path: backup });
|
||||
}
|
||||
|
||||
bail!("Could not write into a backup directory");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
pub(crate) id: DocumentId,
|
||||
text: Rope,
|
||||
|
@ -951,6 +1101,8 @@ impl Document {
|
|||
let encoding_with_bom_info = (self.encoding, self.has_bom);
|
||||
let last_saved_time = self.last_saved_time;
|
||||
|
||||
let bck_config = self.config.clone().load().backup.clone();
|
||||
|
||||
// We encode the file according to the `Document`'s encoding.
|
||||
let future = async move {
|
||||
use tokio::fs;
|
||||
|
@ -975,103 +1127,131 @@ impl Document {
|
|||
}
|
||||
}
|
||||
}
|
||||
let write_path = tokio::fs::read_link(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|p| {
|
||||
if p.is_relative() {
|
||||
path.parent().map(|parent| parent.join(p))
|
||||
} else {
|
||||
Some(p)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| path.clone());
|
||||
|
||||
if readonly(&write_path) {
|
||||
if readonly(&path) {
|
||||
bail!(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Path is read only"
|
||||
));
|
||||
}
|
||||
|
||||
// Assume it is a hardlink to prevent data loss if the metadata cant be read (e.g. on certain Windows configurations)
|
||||
let is_hardlink = helix_stdx::faccess::hardlink_count(&write_path).unwrap_or(2) > 1;
|
||||
let backup = if path.exists() {
|
||||
let path_ = write_path.clone();
|
||||
// hacks: we use tempfile to handle the complex task of creating
|
||||
// non clobbered temporary path for us we don't want
|
||||
// the whole automatically delete path on drop thing
|
||||
// since the path doesn't exist yet, we just want
|
||||
// the path
|
||||
tokio::task::spawn_blocking(move || -> Option<PathBuf> {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix(path_.file_name()?).suffix(".bck");
|
||||
|
||||
let backup_path = if is_hardlink {
|
||||
builder
|
||||
.make_in(path_.parent()?, |backup| std::fs::copy(&path_, backup))
|
||||
.ok()?
|
||||
.into_temp_path()
|
||||
} else {
|
||||
builder
|
||||
.make_in(path_.parent()?, |backup| std::fs::rename(&path_, backup))
|
||||
.ok()?
|
||||
.into_temp_path()
|
||||
};
|
||||
|
||||
backup_path.keep().ok()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
// Use a backup file
|
||||
let meta = if path.exists() {
|
||||
Some(tokio::fs::metadata(&path).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut bck = None;
|
||||
let write_result: Result<(), Error> = async {
|
||||
if path.exists() && bck_config.kind != crate::editor::BackupKind::None {
|
||||
match Backup::from(path.clone(), &bck_config).await {
|
||||
Ok(b) => bck = Some(b),
|
||||
Err(_) if force => {}
|
||||
Err(e) => bail!("Could not create backup: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let write_result: anyhow::Result<_> = async {
|
||||
let mut dst = tokio::fs::File::create(&write_path).await?;
|
||||
to_writer(&mut dst, encoding_with_bom_info, &text).await?;
|
||||
dst.sync_all().await?;
|
||||
Ok(())
|
||||
if let Some(ref bck) = bck {
|
||||
// SECURITY: Ensure that the created file has the same perms as the original file
|
||||
let mut dst = if !bck.copy {
|
||||
#[cfg(unix)]
|
||||
let meta = meta.as_ref().unwrap();
|
||||
|
||||
let mut open_opt = tokio::fs::OpenOptions::new();
|
||||
open_opt.read(true).write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = meta.permissions().mode();
|
||||
open_opt.mode(mode);
|
||||
}
|
||||
|
||||
let file = open_opt.open(&path).await?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
helix_stdx::faccess::fchown(
|
||||
file.as_fd(),
|
||||
Some(meta.uid()),
|
||||
Some(meta.gid()),
|
||||
)?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let from = path.clone();
|
||||
let to = bck.path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), Error> {
|
||||
helix_stdx::faccess::copy_ownership(&from, &to)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
file
|
||||
} else {
|
||||
// SECURITY: Backup copy already exists
|
||||
tokio::fs::File::create(&path).await?
|
||||
};
|
||||
|
||||
to_writer(&mut dst, encoding_with_bom_info, &text).await?;
|
||||
dst.sync_all().await?;
|
||||
Ok(())
|
||||
} else {
|
||||
let mut dst = tokio::fs::File::create(&path).await?;
|
||||
to_writer(&mut dst, encoding_with_bom_info, &text).await?;
|
||||
dst.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
.await;
|
||||
|
||||
let save_time = match fs::metadata(&write_path).await {
|
||||
let save_time = match fs::metadata(&path).await {
|
||||
Ok(metadata) => metadata.modified().map_or(SystemTime::now(), |mtime| mtime),
|
||||
Err(_) => SystemTime::now(),
|
||||
};
|
||||
|
||||
if let Some(backup) = backup {
|
||||
if is_hardlink {
|
||||
let mut delete = true;
|
||||
if write_result.is_err() {
|
||||
// Restore backup
|
||||
let _ = tokio::fs::copy(&backup, &write_path).await.map_err(|e| {
|
||||
delete = false;
|
||||
if let Some(bck) = bck {
|
||||
let mut delete_bck = true;
|
||||
|
||||
// Attempt to restore backup
|
||||
if write_result.is_err() {
|
||||
// If original file no longer exists, then backup is renamed to original file
|
||||
if !path.exists() {
|
||||
delete_bck = false;
|
||||
if tokio::fs::rename(&bck.path, &path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to restore backup on write failure: {e}")
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
// Reset timestamps
|
||||
let meta = meta.as_ref().unwrap();
|
||||
let atime = FileTime::from_last_access_time(meta);
|
||||
let mtime = FileTime::from_last_modification_time(meta);
|
||||
filetime::set_file_times(&path, atime, mtime)?;
|
||||
}
|
||||
} else if bck.copy {
|
||||
// Restore backup from copy
|
||||
let _ = tokio::fs::copy(&bck.path, &path).await.map_err(|e| {
|
||||
delete_bck = false;
|
||||
log::error!("Failed to restore backup on write failure: {e}")
|
||||
});
|
||||
} else {
|
||||
delete_bck = false;
|
||||
// restore backup
|
||||
let _ = tokio::fs::rename(&bck.path, &path).await.map_err(|e| {
|
||||
log::error!("Failed to restore backup on write failure: {e}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if delete {
|
||||
// Delete backup
|
||||
let _ = tokio::fs::remove_file(backup)
|
||||
.await
|
||||
.map_err(|e| log::error!("Failed to remove backup file on write: {e}"));
|
||||
}
|
||||
} else if write_result.is_err() {
|
||||
// restore backup
|
||||
let _ = tokio::fs::rename(&backup, &write_path)
|
||||
.await
|
||||
.map_err(|e| log::error!("Failed to restore backup on write failure: {e}"));
|
||||
} else {
|
||||
// copy metadata and delete backup
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let _ = copy_metadata(&backup, &write_path)
|
||||
.map_err(|e| log::error!("Failed to copy metadata on write: {e}"));
|
||||
let _ = std::fs::remove_file(backup)
|
||||
.map_err(|e| log::error!("Failed to remove backup file on write: {e}"));
|
||||
})
|
||||
.await;
|
||||
// Delete backup if we're done with it
|
||||
if delete_bck {
|
||||
let _ = tokio::fs::remove_file(bck.path).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -360,6 +360,62 @@ pub struct Config {
|
|||
pub end_of_line_diagnostics: DiagnosticFilter,
|
||||
// Set to override the default clipboard provider
|
||||
pub clipboard_provider: ClipboardProvider,
|
||||
pub backup: BackupConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "kebab-case", default)]
|
||||
pub struct BackupConfig {
|
||||
pub kind: BackupKind,
|
||||
#[serde(deserialize_with = "deserialize_non_empty_vec")]
|
||||
pub directories: Vec<PathBuf>,
|
||||
#[serde(deserialize_with = "deserialize_non_empty_str")]
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
impl Default for BackupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: BackupKind::Auto,
|
||||
directories: vec![helix_loader::state_dir().join("backup")],
|
||||
extension: String::from("bck"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_non_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let vec = Vec::<T>::deserialize(deserializer)?;
|
||||
if vec.is_empty() {
|
||||
return Err(<D::Error as Error>::custom("vector cannot be empty!"));
|
||||
}
|
||||
Ok(vec)
|
||||
}
|
||||
|
||||
pub fn deserialize_non_empty_str<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() {
|
||||
return Err(<D::Error as Error>::custom("string cannot be empty"));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum BackupKind {
|
||||
None,
|
||||
Copy,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
|
||||
|
@ -1004,6 +1060,7 @@ impl Default for Config {
|
|||
inline_diagnostics: InlineDiagnosticsConfig::default(),
|
||||
end_of_line_diagnostics: DiagnosticFilter::Disable,
|
||||
clipboard_provider: ClipboardProvider::default(),
|
||||
backup: BackupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue