Merge remote-tracking branch 'backup/open'

This commit is contained in:
Kalle Carlbark 2025-01-02 14:22:06 +01:00
commit 7e2bdbe6df
No known key found for this signature in database
10 changed files with 563 additions and 117 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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()
}

View file

@ -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(())
}

View file

@ -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::{

View file

@ -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;

View file

@ -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)]

View file

@ -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()?;

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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(),
}
}
}