Skip to main content

fresh/model/buffer/
save.rs

1//! Save/write-recipe logic for `TextBuffer`.
2//!
3//! Types: `SudoSaveRequired`, `WriteRecipe`, `RecipeAction`.
4//! Free fns: `build_write_recipe`, save-to-disk helpers that only
5//! need `&dyn FileSystem` + local arguments.
6
7use super::file_kind::BufferFileKind;
8use super::format::{self, BufferFormat};
9use super::persistence::Persistence;
10use crate::model::encoding::Encoding;
11use crate::model::filesystem::{FileMetadata, FileSystem, FileWriter, WriteOp};
12use crate::model::piece_tree::{BufferData, BufferLocation, PieceTree, StringBuffer};
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17// ---------------------------------------------------------------------------
18// SudoSaveRequired
19// ---------------------------------------------------------------------------
20
21/// Error returned when a file save operation requires elevated privileges.
22///
23/// This error contains all the information needed to perform the save via sudo
24/// in a single operation, preserving original file ownership and permissions.
25#[derive(Debug, Clone, PartialEq)]
26pub struct SudoSaveRequired {
27    /// Path to the temporary file containing the new content
28    pub temp_path: PathBuf,
29    /// Destination path where the file should be saved
30    pub dest_path: PathBuf,
31    /// Original file owner (UID)
32    pub uid: u32,
33    /// Original file group (GID)
34    pub gid: u32,
35    /// Original file permissions (mode)
36    pub mode: u32,
37}
38
39impl std::fmt::Display for SudoSaveRequired {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(
42            f,
43            "Permission denied saving to {}. Use sudo to complete the operation.",
44            self.dest_path.display()
45        )
46    }
47}
48
49impl std::error::Error for SudoSaveRequired {}
50
51// ---------------------------------------------------------------------------
52// WriteRecipe / RecipeAction
53// ---------------------------------------------------------------------------
54
55/// A write recipe built from the piece tree for saving
56pub(crate) struct WriteRecipe {
57    /// The source file path for Copy operations (if any)
58    pub(crate) src_path: Option<PathBuf>,
59    /// Data chunks for Insert operations (owned to avoid lifetime issues)
60    pub(crate) insert_data: Vec<Vec<u8>>,
61    /// Sequence of actions to build the output file
62    pub(crate) actions: Vec<RecipeAction>,
63}
64
65/// An action in a write recipe
66#[derive(Debug, Clone, Copy)]
67pub(crate) enum RecipeAction {
68    /// Copy bytes from source file at offset
69    Copy { offset: u64, len: u64 },
70    /// Insert data from insert_data[index]
71    Insert { index: usize },
72}
73
74impl WriteRecipe {
75    /// Convert the recipe to WriteOp slice for use with filesystem write_patched
76    pub(crate) fn to_write_ops(&self) -> Vec<WriteOp<'_>> {
77        self.actions
78            .iter()
79            .map(|action| match action {
80                RecipeAction::Copy { offset, len } => WriteOp::Copy {
81                    offset: *offset,
82                    len: *len,
83                },
84                RecipeAction::Insert { index } => WriteOp::Insert {
85                    data: &self.insert_data[*index],
86                },
87            })
88            .collect()
89    }
90
91    /// Check if this recipe has any Copy operations
92    pub(crate) fn has_copy_ops(&self) -> bool {
93        self.actions
94            .iter()
95            .any(|a| matches!(a, RecipeAction::Copy { .. }))
96    }
97
98    /// Flatten all Insert operations into a single buffer.
99    /// Only valid when has_copy_ops() returns false.
100    pub(crate) fn flatten_inserts(&self) -> Vec<u8> {
101        let mut result = Vec::new();
102        for action in &self.actions {
103            if let RecipeAction::Insert { index } = action {
104                result.extend_from_slice(&self.insert_data[*index]);
105            }
106        }
107        result
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Free functions (extracted from impl TextBuffer)
113// ---------------------------------------------------------------------------
114
115/// Check if we should use in-place writing to preserve file ownership.
116/// Returns true if the file exists and is owned by a different user.
117/// On Unix, only root or the file owner can change file ownership with chown.
118/// When the current user is not the file owner, using atomic write (temp file + rename)
119/// would change the file's ownership to the current user. To preserve ownership,
120/// we must write directly to the existing file instead.
121pub(super) fn should_use_inplace_write(
122    fs: &Arc<dyn FileSystem + Send + Sync>,
123    dest_path: &Path,
124) -> bool {
125    !fs.is_owner(dest_path)
126}
127
128/// Build a write recipe from the piece tree for saving.
129///
130/// This creates a recipe of Copy and Insert operations that can reconstruct
131/// the buffer content. Copy operations reference unchanged regions in the
132/// source file, while Insert operations contain new/modified data.
133///
134/// # Returns
135/// A WriteRecipe with the source path, insert data, and sequence of actions.
136pub(super) fn build_write_recipe(
137    piece_tree: &PieceTree,
138    buffers: &[StringBuffer],
139    format: &BufferFormat,
140    file_kind: &BufferFileKind,
141    persistence: &Persistence,
142) -> io::Result<WriteRecipe> {
143    let total = piece_tree.total_bytes();
144
145    // Determine the source file for Copy operations (if any)
146    // We can only use Copy if:
147    // 1. We have a source file path
148    // 2. The source file exists
149    // 3. No line ending conversion is needed
150    // 4. No encoding conversion is needed
151    let needs_line_ending_conversion = format.line_ending_changed_since_load();
152    // We need encoding conversion if:
153    // - NOT a binary file (binary files preserve raw bytes), AND
154    // - Either the encoding changed from the original, OR
155    // - The target encoding isn't plain UTF-8/ASCII (since internal storage is UTF-8)
156    // For example: UTF-8 BOM files are stored as UTF-8, so we need to add BOM on save
157    let needs_encoding_conversion = !file_kind.is_binary()
158        && (format.encoding_changed_since_load()
159            || !matches!(format.encoding(), Encoding::Utf8 | Encoding::Ascii));
160    let needs_conversion = needs_line_ending_conversion || needs_encoding_conversion;
161
162    let src_path_for_copy: Option<&Path> = if needs_conversion {
163        None
164    } else {
165        persistence
166            .file_path()
167            .filter(|p| persistence.fs().exists(p))
168    };
169    let target_ending = format.line_ending();
170    let target_encoding = format.encoding();
171
172    let mut insert_data: Vec<Vec<u8>> = Vec::new();
173    let mut actions: Vec<RecipeAction> = Vec::new();
174
175    // Add BOM as the first piece if the target encoding has one
176    if let Some(bom) = target_encoding.bom_bytes() {
177        insert_data.push(bom.to_vec());
178        actions.push(RecipeAction::Insert { index: 0 });
179    }
180
181    for piece_view in piece_tree.iter_pieces_in_range(0, total) {
182        let buffer_id = piece_view.location.buffer_id();
183        let buffer = buffers.get(buffer_id).ok_or_else(|| {
184            io::Error::new(
185                io::ErrorKind::InvalidData,
186                format!("Buffer {} not found", buffer_id),
187            )
188        })?;
189
190        match &buffer.data {
191            // Unloaded buffer: can use Copy if same source file, else load and send
192            BufferData::Unloaded {
193                file_path,
194                file_offset,
195                ..
196            } => {
197                // Can only use Copy if:
198                // - This is a Stored piece (original file content)
199                // - We have a valid source for copying
200                // - This buffer is from that source
201                // - No line ending or encoding conversion needed
202                let can_copy = matches!(piece_view.location, BufferLocation::Stored(_))
203                    && src_path_for_copy.is_some_and(|src| file_path == src);
204
205                if can_copy {
206                    let src_offset = (*file_offset + piece_view.buffer_offset) as u64;
207                    actions.push(RecipeAction::Copy {
208                        offset: src_offset,
209                        len: piece_view.bytes as u64,
210                    });
211                    continue;
212                }
213
214                // Need to load and send this unloaded region
215                // This happens when: different source file, or conversion needed
216                let data = persistence.fs().read_range(
217                    file_path,
218                    (*file_offset + piece_view.buffer_offset) as u64,
219                    piece_view.bytes,
220                )?;
221
222                let data = if needs_line_ending_conversion {
223                    format::convert_line_endings_to(&data, target_ending)
224                } else {
225                    data
226                };
227
228                // Convert encoding if needed
229                let data = if needs_encoding_conversion {
230                    format::convert_to_encoding(&data, target_encoding)
231                } else {
232                    data
233                };
234
235                let index = insert_data.len();
236                insert_data.push(data);
237                actions.push(RecipeAction::Insert { index });
238            }
239
240            // Loaded data: send as Insert
241            BufferData::Loaded { data, .. } => {
242                let start = piece_view.buffer_offset;
243                let end = start + piece_view.bytes;
244                let chunk = &data[start..end];
245
246                let chunk = if needs_line_ending_conversion {
247                    format::convert_line_endings_to(chunk, target_ending)
248                } else {
249                    chunk.to_vec()
250                };
251
252                // Convert encoding if needed
253                let chunk = if needs_encoding_conversion {
254                    format::convert_to_encoding(&chunk, target_encoding)
255                } else {
256                    chunk
257                };
258
259                let index = insert_data.len();
260                insert_data.push(chunk);
261                actions.push(RecipeAction::Insert { index });
262            }
263        }
264    }
265
266    Ok(WriteRecipe {
267        src_path: src_path_for_copy.map(|p| p.to_path_buf()),
268        insert_data,
269        actions,
270    })
271}
272
273/// Create a temporary file for saving.
274///
275/// Tries to create the file in the same directory as the destination file first
276/// to allow for an atomic rename. If that fails (e.g., due to directory permissions),
277/// falls back to the system temporary directory.
278pub(super) fn create_temp_file(
279    fs: &Arc<dyn FileSystem + Send + Sync>,
280    dest_path: &Path,
281) -> io::Result<(PathBuf, Box<dyn FileWriter>)> {
282    // Try creating in same directory first
283    let same_dir_temp = fs.temp_path_for(dest_path);
284    match fs.create_file(&same_dir_temp) {
285        Ok(file) => Ok((same_dir_temp, file)),
286        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
287            // Fallback to system temp directory
288            let temp_path = fs.unique_temp_path(dest_path);
289            let file = fs.create_file(&temp_path)?;
290            Ok((temp_path, file))
291        }
292        Err(e) => Err(e),
293    }
294}
295
296/// Create a temporary file in the recovery directory for in-place writes.
297/// This allows recovery if a crash occurs during the in-place write operation.
298pub(super) fn create_recovery_temp_file(
299    fs: &Arc<dyn FileSystem + Send + Sync>,
300    dest_path: &Path,
301) -> io::Result<(PathBuf, Box<dyn FileWriter>)> {
302    // Get recovery directory: $XDG_DATA_HOME/fresh/recovery or ~/.local/share/fresh/recovery
303    let recovery_dir = crate::input::input_history::get_data_dir()
304        .map(|d| d.join("recovery"))
305        .unwrap_or_else(|_| std::env::temp_dir());
306
307    // Ensure directory exists
308    fs.create_dir_all(&recovery_dir)?;
309
310    // Create unique filename based on destination file and timestamp
311    let file_name = dest_path
312        .file_name()
313        .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
314    let timestamp = std::time::SystemTime::now()
315        .duration_since(std::time::UNIX_EPOCH)
316        .map(|d| d.as_nanos())
317        .unwrap_or(0);
318    let pid = std::process::id();
319
320    let temp_name = format!(
321        ".inplace-{}-{}-{}.tmp",
322        file_name.to_string_lossy(),
323        pid,
324        timestamp
325    );
326    let temp_path = recovery_dir.join(temp_name);
327
328    let file = fs.create_file(&temp_path)?;
329    Ok((temp_path, file))
330}
331
332/// Get the path for in-place write recovery metadata.
333/// Uses the same recovery directory as temp files.
334pub(super) fn inplace_recovery_meta_path(dest_path: &Path) -> PathBuf {
335    let recovery_dir = crate::input::input_history::get_data_dir()
336        .map(|d| d.join("recovery"))
337        .unwrap_or_else(|_| std::env::temp_dir());
338
339    let hash = crate::services::recovery::path_hash(dest_path);
340    recovery_dir.join(format!("{}.inplace.json", hash))
341}
342
343/// Write in-place recovery metadata using fs.
344/// This is called before the dangerous streaming step so we can recover on crash.
345pub(super) fn write_inplace_recovery_meta(
346    fs: &Arc<dyn FileSystem + Send + Sync>,
347    meta_path: &Path,
348    dest_path: &Path,
349    temp_path: &Path,
350    original_metadata: &Option<FileMetadata>,
351) -> io::Result<()> {
352    #[cfg(unix)]
353    let (uid, gid, mode) = original_metadata
354        .as_ref()
355        .map(|m| {
356            (
357                m.uid.unwrap_or(0),
358                m.gid.unwrap_or(0),
359                m.permissions.as_ref().map(|p| p.mode()).unwrap_or(0o644),
360            )
361        })
362        .unwrap_or((0, 0, 0o644));
363    #[cfg(not(unix))]
364    let (uid, gid, mode) = (0u32, 0u32, 0o644u32);
365
366    let recovery = crate::services::recovery::InplaceWriteRecovery::new(
367        dest_path.to_path_buf(),
368        temp_path.to_path_buf(),
369        uid,
370        gid,
371        mode,
372    );
373
374    let json = serde_json::to_string_pretty(&recovery)
375        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
376
377    fs.write_file(meta_path, json.as_bytes())
378}
379
380/// Write using in-place mode to preserve file ownership.
381///
382/// This is used when the file is owned by a different user and we need
383/// to write directly to the existing file to preserve its ownership.
384///
385/// The approach:
386/// 1. Write the recipe to a temp file first (reads from original, writes to temp)
387/// 2. Stream the temp file content to the destination file (truncates and writes)
388/// 3. Delete the temp file
389///
390/// This avoids the bug where truncating the destination before reading Copy chunks
391/// would corrupt the file. It also works for huge files since we stream in chunks.
392pub(super) fn save_with_inplace_write(
393    fs: &Arc<dyn FileSystem + Send + Sync>,
394    dest_path: &Path,
395    recipe: &WriteRecipe,
396) -> anyhow::Result<()> {
397    let original_metadata = fs.metadata_if_exists(dest_path);
398
399    // Optimization: if no Copy ops, we can write directly without a temp file
400    // (same as the non-inplace path for small files)
401    if !recipe.has_copy_ops() {
402        let data = recipe.flatten_inserts();
403        return write_data_inplace(fs, dest_path, &data, original_metadata);
404    }
405
406    // Step 1: Write recipe to a temp file in the recovery directory
407    // This reads Copy chunks from the original file (still intact) and writes to temp.
408    // Using the recovery directory allows crash recovery if the operation fails.
409    let (temp_path, mut temp_file) = create_recovery_temp_file(fs, dest_path)?;
410    if let Err(e) = write_recipe_to_file(fs, &mut temp_file, recipe) {
411        // Best-effort cleanup of temp file on write failure
412        #[allow(clippy::let_underscore_must_use)]
413        let _ = fs.remove_file(&temp_path);
414        return Err(e.into());
415    }
416    temp_file.sync_all()?;
417    drop(temp_file);
418
419    // Step 1.5: Save recovery metadata before the dangerous step
420    // If we crash during step 2, this metadata + temp file allows recovery
421    let recovery_meta_path = inplace_recovery_meta_path(dest_path);
422    // Best effort - don't fail the save if we can't write recovery metadata
423    #[allow(clippy::let_underscore_must_use)]
424    let _ = write_inplace_recovery_meta(
425        fs,
426        &recovery_meta_path,
427        dest_path,
428        &temp_path,
429        &original_metadata,
430    );
431
432    // Step 2: Stream temp file content to destination
433    // Now it's safe to truncate the destination since all data is in temp
434    match fs.open_file_for_write(dest_path) {
435        Ok(mut out_file) => {
436            if let Err(e) = stream_file_to_writer(fs, &temp_path, &mut out_file) {
437                // Don't delete temp file or recovery metadata - allow recovery
438                return Err(e.into());
439            }
440            out_file.sync_all()?;
441            // Success! Clean up temp file and recovery metadata (best-effort)
442            #[allow(clippy::let_underscore_must_use)]
443            let _ = fs.remove_file(&temp_path);
444            #[allow(clippy::let_underscore_must_use)]
445            let _ = fs.remove_file(&recovery_meta_path);
446            Ok(())
447        }
448        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
449            // Can't write to destination - trigger sudo fallback
450            // Keep temp file for sudo to use, clean up recovery metadata (best-effort)
451            #[allow(clippy::let_underscore_must_use)]
452            let _ = fs.remove_file(&recovery_meta_path);
453            Err(make_sudo_error(temp_path, dest_path, original_metadata))
454        }
455        Err(e) => {
456            // Don't delete temp file or recovery metadata - allow recovery
457            Err(e.into())
458        }
459    }
460}
461
462/// Write data directly to a file in-place, with sudo fallback on permission denied.
463pub(super) fn write_data_inplace(
464    fs: &Arc<dyn FileSystem + Send + Sync>,
465    dest_path: &Path,
466    data: &[u8],
467    original_metadata: Option<FileMetadata>,
468) -> anyhow::Result<()> {
469    match fs.open_file_for_write(dest_path) {
470        Ok(mut out_file) => {
471            out_file.write_all(data)?;
472            out_file.sync_all()?;
473            Ok(())
474        }
475        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
476            // Create temp file for sudo fallback
477            let (temp_path, mut temp_file) = create_temp_file(fs, dest_path)?;
478            temp_file.write_all(data)?;
479            temp_file.sync_all()?;
480            drop(temp_file);
481            Err(make_sudo_error(temp_path, dest_path, original_metadata))
482        }
483        Err(e) => Err(e.into()),
484    }
485}
486
487/// Stream a file's content to a writer in chunks to avoid memory issues with large files.
488pub(super) fn stream_file_to_writer(
489    fs: &Arc<dyn FileSystem + Send + Sync>,
490    src_path: &Path,
491    out_file: &mut Box<dyn FileWriter>,
492) -> io::Result<()> {
493    const CHUNK_SIZE: usize = 1024 * 1024; // 1MB chunks
494
495    let file_size = fs.metadata(src_path)?.size;
496    let mut offset = 0u64;
497
498    while offset < file_size {
499        let remaining = file_size - offset;
500        let chunk_len = std::cmp::min(remaining, CHUNK_SIZE as u64) as usize;
501        let chunk = fs.read_range(src_path, offset, chunk_len)?;
502        out_file.write_all(&chunk)?;
503        offset += chunk_len as u64;
504    }
505
506    Ok(())
507}
508
509/// Write the recipe content to a file writer.
510pub(super) fn write_recipe_to_file(
511    fs: &Arc<dyn FileSystem + Send + Sync>,
512    out_file: &mut Box<dyn FileWriter>,
513    recipe: &WriteRecipe,
514) -> io::Result<()> {
515    for action in &recipe.actions {
516        match action {
517            RecipeAction::Copy { offset, len } => {
518                // Read from source and write to output
519                let src_path = recipe.src_path.as_ref().ok_or_else(|| {
520                    io::Error::new(io::ErrorKind::InvalidData, "Copy action without source")
521                })?;
522                let data = fs.read_range(src_path, *offset, *len as usize)?;
523                out_file.write_all(&data)?;
524            }
525            RecipeAction::Insert { index } => {
526                out_file.write_all(&recipe.insert_data[*index])?;
527            }
528        }
529    }
530    Ok(())
531}
532
533/// Internal helper to create a SudoSaveRequired error.
534pub(super) fn make_sudo_error(
535    temp_path: PathBuf,
536    dest_path: &Path,
537    original_metadata: Option<FileMetadata>,
538) -> anyhow::Error {
539    #[cfg(unix)]
540    let (uid, gid, mode) = if let Some(ref meta) = original_metadata {
541        (
542            meta.uid.unwrap_or(0),
543            meta.gid.unwrap_or(0),
544            meta.permissions
545                .as_ref()
546                .map(|p| p.mode() & 0o7777)
547                .unwrap_or(0),
548        )
549    } else {
550        (0, 0, 0)
551    };
552    #[cfg(not(unix))]
553    let (uid, gid, mode) = (0u32, 0u32, 0u32);
554
555    let _ = original_metadata; // suppress unused warning on non-Unix
556
557    anyhow::anyhow!(SudoSaveRequired {
558        temp_path,
559        dest_path: dest_path.to_path_buf(),
560        uid,
561        gid,
562        mode,
563    })
564}