use super::file_kind::BufferFileKind;
use super::format::{self, BufferFormat};
use super::persistence::Persistence;
use crate::model::encoding::Encoding;
use crate::model::filesystem::{FileMetadata, FileSystem, FileWriter, WriteOp};
use crate::model::piece_tree::{BufferData, BufferLocation, PieceTree, StringBuffer};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
pub struct SudoSaveRequired {
pub temp_path: PathBuf,
pub dest_path: PathBuf,
pub uid: u32,
pub gid: u32,
pub mode: u32,
}
impl std::fmt::Display for SudoSaveRequired {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Permission denied saving to {}. Use sudo to complete the operation.",
self.dest_path.display()
)
}
}
impl std::error::Error for SudoSaveRequired {}
pub(crate) struct WriteRecipe {
pub(crate) src_path: Option<PathBuf>,
pub(crate) insert_data: Vec<Vec<u8>>,
pub(crate) actions: Vec<RecipeAction>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum RecipeAction {
Copy { offset: u64, len: u64 },
Insert { index: usize },
}
impl WriteRecipe {
pub(crate) fn to_write_ops(&self) -> Vec<WriteOp<'_>> {
self.actions
.iter()
.map(|action| match action {
RecipeAction::Copy { offset, len } => WriteOp::Copy {
offset: *offset,
len: *len,
},
RecipeAction::Insert { index } => WriteOp::Insert {
data: &self.insert_data[*index],
},
})
.collect()
}
pub(crate) fn has_copy_ops(&self) -> bool {
self.actions
.iter()
.any(|a| matches!(a, RecipeAction::Copy { .. }))
}
pub(crate) fn flatten_inserts(&self) -> Vec<u8> {
let mut result = Vec::new();
for action in &self.actions {
if let RecipeAction::Insert { index } = action {
result.extend_from_slice(&self.insert_data[*index]);
}
}
result
}
}
pub(super) fn should_use_inplace_write(
fs: &Arc<dyn FileSystem + Send + Sync>,
dest_path: &Path,
) -> bool {
!fs.is_owner(dest_path)
}
pub(super) fn build_write_recipe(
piece_tree: &PieceTree,
buffers: &[StringBuffer],
format: &BufferFormat,
file_kind: &BufferFileKind,
persistence: &Persistence,
) -> io::Result<WriteRecipe> {
let total = piece_tree.total_bytes();
let needs_line_ending_conversion = format.line_ending_changed_since_load();
let needs_encoding_conversion = !file_kind.is_binary()
&& (format.encoding_changed_since_load()
|| !matches!(format.encoding(), Encoding::Utf8 | Encoding::Ascii));
let needs_conversion = needs_line_ending_conversion || needs_encoding_conversion;
let src_path_for_copy: Option<&Path> = if needs_conversion {
None
} else {
persistence
.file_path()
.filter(|p| persistence.fs().exists(p))
};
let target_ending = format.line_ending();
let target_encoding = format.encoding();
let mut insert_data: Vec<Vec<u8>> = Vec::new();
let mut actions: Vec<RecipeAction> = Vec::new();
if let Some(bom) = target_encoding.bom_bytes() {
insert_data.push(bom.to_vec());
actions.push(RecipeAction::Insert { index: 0 });
}
for piece_view in piece_tree.iter_pieces_in_range(0, total) {
let buffer_id = piece_view.location.buffer_id();
let buffer = buffers.get(buffer_id).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Buffer {} not found", buffer_id),
)
})?;
match &buffer.data {
BufferData::Unloaded {
file_path,
file_offset,
..
} => {
let can_copy = matches!(piece_view.location, BufferLocation::Stored(_))
&& src_path_for_copy.is_some_and(|src| file_path == src);
if can_copy {
let src_offset = (*file_offset + piece_view.buffer_offset) as u64;
actions.push(RecipeAction::Copy {
offset: src_offset,
len: piece_view.bytes as u64,
});
continue;
}
let data = persistence.fs().read_range(
file_path,
(*file_offset + piece_view.buffer_offset) as u64,
piece_view.bytes,
)?;
let data = if needs_line_ending_conversion {
format::convert_line_endings_to(&data, target_ending)
} else {
data
};
let data = if needs_encoding_conversion {
format::convert_to_encoding(&data, target_encoding)
} else {
data
};
let index = insert_data.len();
insert_data.push(data);
actions.push(RecipeAction::Insert { index });
}
BufferData::Loaded { data, .. } => {
let start = piece_view.buffer_offset;
let end = start + piece_view.bytes;
let chunk = &data[start..end];
let chunk = if needs_line_ending_conversion {
format::convert_line_endings_to(chunk, target_ending)
} else {
chunk.to_vec()
};
let chunk = if needs_encoding_conversion {
format::convert_to_encoding(&chunk, target_encoding)
} else {
chunk
};
let index = insert_data.len();
insert_data.push(chunk);
actions.push(RecipeAction::Insert { index });
}
}
}
Ok(WriteRecipe {
src_path: src_path_for_copy.map(|p| p.to_path_buf()),
insert_data,
actions,
})
}
pub(super) fn create_temp_file(
fs: &Arc<dyn FileSystem + Send + Sync>,
dest_path: &Path,
) -> io::Result<(PathBuf, Box<dyn FileWriter>)> {
let same_dir_temp = fs.temp_path_for(dest_path);
match fs.create_file(&same_dir_temp) {
Ok(file) => Ok((same_dir_temp, file)),
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
let temp_path = fs.unique_temp_path(dest_path);
let file = fs.create_file(&temp_path)?;
Ok((temp_path, file))
}
Err(e) => Err(e),
}
}
pub(super) fn create_recovery_temp_file(
fs: &Arc<dyn FileSystem + Send + Sync>,
dest_path: &Path,
) -> io::Result<(PathBuf, Box<dyn FileWriter>)> {
let recovery_dir = crate::input::input_history::get_data_dir()
.map(|d| d.join("recovery"))
.unwrap_or_else(|_| std::env::temp_dir());
fs.create_dir_all(&recovery_dir)?;
let file_name = dest_path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let temp_name = format!(
".inplace-{}-{}-{}.tmp",
file_name.to_string_lossy(),
pid,
timestamp
);
let temp_path = recovery_dir.join(temp_name);
let file = fs.create_file(&temp_path)?;
Ok((temp_path, file))
}
pub(super) fn inplace_recovery_meta_path(dest_path: &Path) -> PathBuf {
let recovery_dir = crate::input::input_history::get_data_dir()
.map(|d| d.join("recovery"))
.unwrap_or_else(|_| std::env::temp_dir());
let hash = crate::services::recovery::path_hash(dest_path);
recovery_dir.join(format!("{}.inplace.json", hash))
}
pub(super) fn write_inplace_recovery_meta(
fs: &Arc<dyn FileSystem + Send + Sync>,
meta_path: &Path,
dest_path: &Path,
temp_path: &Path,
original_metadata: &Option<FileMetadata>,
) -> io::Result<()> {
#[cfg(unix)]
let (uid, gid, mode) = original_metadata
.as_ref()
.map(|m| {
(
m.uid.unwrap_or(0),
m.gid.unwrap_or(0),
m.permissions.as_ref().map(|p| p.mode()).unwrap_or(0o644),
)
})
.unwrap_or((0, 0, 0o644));
#[cfg(not(unix))]
let (uid, gid, mode) = (0u32, 0u32, 0o644u32);
let recovery = crate::services::recovery::InplaceWriteRecovery::new(
dest_path.to_path_buf(),
temp_path.to_path_buf(),
uid,
gid,
mode,
);
let json = serde_json::to_string_pretty(&recovery)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs.write_file(meta_path, json.as_bytes())
}
pub(super) fn save_with_inplace_write(
fs: &Arc<dyn FileSystem + Send + Sync>,
dest_path: &Path,
recipe: &WriteRecipe,
) -> anyhow::Result<()> {
let original_metadata = fs.metadata_if_exists(dest_path);
if !recipe.has_copy_ops() {
let data = recipe.flatten_inserts();
return write_data_inplace(fs, dest_path, &data, original_metadata);
}
let (temp_path, mut temp_file) = create_recovery_temp_file(fs, dest_path)?;
if let Err(e) = write_recipe_to_file(fs, &mut temp_file, recipe) {
#[allow(clippy::let_underscore_must_use)]
let _ = fs.remove_file(&temp_path);
return Err(e.into());
}
temp_file.sync_all()?;
drop(temp_file);
let recovery_meta_path = inplace_recovery_meta_path(dest_path);
#[allow(clippy::let_underscore_must_use)]
let _ = write_inplace_recovery_meta(
fs,
&recovery_meta_path,
dest_path,
&temp_path,
&original_metadata,
);
match fs.open_file_for_write(dest_path) {
Ok(mut out_file) => {
if let Err(e) = stream_file_to_writer(fs, &temp_path, &mut out_file) {
return Err(e.into());
}
out_file.sync_all()?;
#[allow(clippy::let_underscore_must_use)]
let _ = fs.remove_file(&temp_path);
#[allow(clippy::let_underscore_must_use)]
let _ = fs.remove_file(&recovery_meta_path);
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
#[allow(clippy::let_underscore_must_use)]
let _ = fs.remove_file(&recovery_meta_path);
Err(make_sudo_error(temp_path, dest_path, original_metadata))
}
Err(e) => {
Err(e.into())
}
}
}
pub(super) fn write_data_inplace(
fs: &Arc<dyn FileSystem + Send + Sync>,
dest_path: &Path,
data: &[u8],
original_metadata: Option<FileMetadata>,
) -> anyhow::Result<()> {
match fs.open_file_for_write(dest_path) {
Ok(mut out_file) => {
out_file.write_all(data)?;
out_file.sync_all()?;
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
let (temp_path, mut temp_file) = create_temp_file(fs, dest_path)?;
temp_file.write_all(data)?;
temp_file.sync_all()?;
drop(temp_file);
Err(make_sudo_error(temp_path, dest_path, original_metadata))
}
Err(e) => Err(e.into()),
}
}
pub(super) fn stream_file_to_writer(
fs: &Arc<dyn FileSystem + Send + Sync>,
src_path: &Path,
out_file: &mut Box<dyn FileWriter>,
) -> io::Result<()> {
const CHUNK_SIZE: usize = 1024 * 1024;
let file_size = fs.metadata(src_path)?.size;
let mut offset = 0u64;
while offset < file_size {
let remaining = file_size - offset;
let chunk_len = std::cmp::min(remaining, CHUNK_SIZE as u64) as usize;
let chunk = fs.read_range(src_path, offset, chunk_len)?;
out_file.write_all(&chunk)?;
offset += chunk_len as u64;
}
Ok(())
}
pub(super) fn write_recipe_to_file(
fs: &Arc<dyn FileSystem + Send + Sync>,
out_file: &mut Box<dyn FileWriter>,
recipe: &WriteRecipe,
) -> io::Result<()> {
for action in &recipe.actions {
match action {
RecipeAction::Copy { offset, len } => {
let src_path = recipe.src_path.as_ref().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "Copy action without source")
})?;
let data = fs.read_range(src_path, *offset, *len as usize)?;
out_file.write_all(&data)?;
}
RecipeAction::Insert { index } => {
out_file.write_all(&recipe.insert_data[*index])?;
}
}
}
Ok(())
}
pub(super) fn make_sudo_error(
temp_path: PathBuf,
dest_path: &Path,
original_metadata: Option<FileMetadata>,
) -> anyhow::Error {
#[cfg(unix)]
let (uid, gid, mode) = if let Some(ref meta) = original_metadata {
(
meta.uid.unwrap_or(0),
meta.gid.unwrap_or(0),
meta.permissions
.as_ref()
.map(|p| p.mode() & 0o7777)
.unwrap_or(0),
)
} else {
(0, 0, 0)
};
#[cfg(not(unix))]
let (uid, gid, mode) = (0u32, 0u32, 0u32);
let _ = original_metadata;
anyhow::anyhow!(SudoSaveRequired {
temp_path,
dest_path: dest_path.to_path_buf(),
uid,
gid,
mode,
})
}