diff --git a/Cargo.lock b/Cargo.lock index 4f2ba254..ce8a941a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 0e7c134d..ad715a11 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -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() } diff --git a/helix-stdx/src/faccess.rs b/helix-stdx/src/faccess.rs index e4c3daf2..8be9df1b 100644 --- a/helix-stdx/src/faccess.rs +++ b/helix-stdx/src/faccess.rs @@ -50,13 +50,6 @@ mod imp { Ok(()) } - fn chown(p: &Path, uid: Option, gid: Option) -> 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 { 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 { 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 { - let file = std::fs::File::open(p)?; + fn file_info(p: &Path) -> io::Result { + 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 { + 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 { + 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 { - 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, gid: Option) -> 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, gid: Option) -> 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(()) } diff --git a/helix-stdx/src/path.rs b/helix-stdx/src/path.rs index 72b233cc..d32c0f59 100644 --- a/helix-stdx/src/path.rs +++ b/helix-stdx/src/path.rs @@ -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 + ?Sized>(path: &T) -> Cow<'_, Path> { } } +fn os_str_as_bytes>(path: P) -> Vec { + 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 { + #[cfg(windows)] + let res = PathBuf::from(std::str::from_utf8(slice)?); + + #[cfg(unix)] + let res = PathBuf::from(::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>(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::{ diff --git a/helix-stdx/tests/path.rs b/helix-stdx/tests/path.rs index cc3c15cb..0bf66370 100644 --- a/helix-stdx/tests/path.rs +++ b/helix-stdx/tests/path.rs @@ -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; diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index feb84fb6..333e5ea0 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -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)] diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs index aba101e9..0be0298e 100644 --- a/helix-term/tests/test/commands/write.rs +++ b/helix-term/tests/test/commands/write.rs @@ -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"), 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()?; diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 6f71fa05..f44ba1a9 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -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" diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 5eb4f9a5..f7fb422c 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -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 { + 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 { - 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; } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index aa8d3227..eef8053a 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -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, + #[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, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + use serde::de::Error; + + let vec = Vec::::deserialize(deserializer)?; + if vec.is_empty() { + return Err(::custom("vector cannot be empty!")); + } + Ok(vec) +} + +pub fn deserialize_non_empty_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + if s.is_empty() { + return Err(::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(), } } }