Skip to main content

twine_core/files/
editing.rs

1use std::fs::File;
2use std::io::{self, Seek, Write};
3use std::os::unix::fs::{MetadataExt, PermissionsExt};
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use rustix::fs::{AtFlags, Mode, OFlags, openat, renameat, unlinkat};
8
9use super::{
10    FileContent, FileError, FilePreview, FileVersion, TEXT_LIMIT, access, text, validate_request,
11};
12
13pub struct FileSaveRequest {
14    pub folder: PathBuf,
15    pub path: PathBuf,
16    pub text: String,
17    pub expected_version: FileVersion,
18    pub overwrite: bool,
19}
20
21#[derive(Debug)]
22pub enum FileSaveOutcome {
23    Saved(FilePreview),
24    Conflict(FilePreview),
25}
26
27pub(crate) fn save(request: &FileSaveRequest) -> Result<FileSaveOutcome, FileError> {
28    validate_request(&request.folder, &[], Some(&request.path))?;
29    if request.text.len() as u64
30        + if request.expected_version.utf8_bom {
31            3
32        } else {
33            0
34        }
35        > TEXT_LIMIT
36    {
37        return Err(FileError::TextTooLarge);
38    }
39    if !text::is_text(request.text.as_bytes()) {
40        return Err(FileError::UnsupportedSave);
41    }
42    let parent = request.path.parent().ok_or(FileError::InvalidRequest)?;
43    let directory = access::open_file(&request.folder, parent, true)?;
44    save_in_directory(request, directory)
45}
46
47fn save_in_directory(
48    request: &FileSaveRequest,
49    directory: File,
50) -> Result<FileSaveOutcome, FileError> {
51    check_parent(request, &directory)?;
52    let name = request.path.file_name().ok_or(FileError::InvalidRequest)?;
53    let current = text::preview_from_file(&request.path, access::open_child(&directory, name));
54    if !request.overwrite && current.version.as_ref() != Some(&request.expected_version) {
55        return Ok(FileSaveOutcome::Conflict(current));
56    }
57    if !matches!(current.content, FileContent::Text(_) | FileContent::Missing) {
58        return Err(FileError::UnsupportedSave);
59    }
60    let mode = if current.content == FileContent::Missing {
61        0o600
62    } else {
63        let original = access::open_child(&directory, name)?;
64        let permissions = original.metadata()?.permissions();
65        if permissions.readonly() {
66            return Err(FileError::Save(io::ErrorKind::PermissionDenied.into()));
67        }
68        permissions.mode() & 0o777
69    };
70    let mut temporary = TemporarySave::new(directory)?;
71    if request.expected_version.utf8_bom {
72        temporary.file.write_all(b"\xef\xbb\xbf")?;
73    }
74    temporary.file.write_all(request.text.as_bytes())?;
75    temporary
76        .file
77        .set_permissions(std::fs::Permissions::from_mode(mode))?;
78    temporary.file.sync_all()?;
79
80    // Recheck after staging the bytes, immediately before replacing the directory entry.
81    // External writers do not share our lock; no portable filesystem operation is a compare-and-swap.
82    let latest = text::preview_from_file(
83        &request.path,
84        access::open_child(&temporary.directory, name),
85    );
86    if latest != current {
87        return Ok(FileSaveOutcome::Conflict(latest));
88    }
89    check_parent(request, &temporary.directory)?;
90    renameat(
91        &temporary.directory,
92        temporary.name.as_str(),
93        &temporary.directory,
94        name,
95    )
96    .map_err(io::Error::from)?;
97    temporary.file.rewind()?;
98    let (content, version) = text::read_file(&temporary.file)?;
99    Ok(FileSaveOutcome::Saved(FilePreview {
100        path: request.path.clone(),
101        content,
102        version,
103    }))
104}
105
106fn check_parent(request: &FileSaveRequest, directory: &File) -> Result<(), FileError> {
107    let parent = request.path.parent().ok_or(FileError::InvalidRequest)?;
108    let current = access::open_file(&request.folder, parent, true)?.metadata()?;
109    let pinned = directory.metadata()?;
110    if (current.dev(), current.ino()) != (pinned.dev(), pinned.ino()) {
111        return Err(FileError::Save(io::Error::other(
112            "The parent folder moved or was replaced. Reopen the file.",
113        )));
114    }
115    Ok(())
116}
117
118/// Staging and cleanup both use the pinned parent descriptor, even if its path is renamed.
119struct TemporarySave {
120    directory: File,
121    file: File,
122    name: String,
123}
124
125impl TemporarySave {
126    fn new(directory: File) -> io::Result<Self> {
127        static NEXT: AtomicU64 = AtomicU64::new(0);
128        for _ in 0..100 {
129            let name = format!(
130                ".twine-save-{}-{}",
131                std::process::id(),
132                NEXT.fetch_add(1, Ordering::Relaxed)
133            );
134            match openat(
135                &directory,
136                name.as_str(),
137                OFlags::CREATE | OFlags::EXCL | OFlags::RDWR | OFlags::CLOEXEC,
138                Mode::RUSR | Mode::WUSR,
139            ) {
140                Ok(file) => {
141                    return Ok(Self {
142                        directory,
143                        file: File::from(file),
144                        name,
145                    });
146                }
147                Err(rustix::io::Errno::EXIST) => {}
148                Err(error) => return Err(error.into()),
149            }
150        }
151        Err(io::Error::new(
152            io::ErrorKind::AlreadyExists,
153            "Could not create a temporary save file.",
154        ))
155    }
156}
157
158impl Drop for TemporarySave {
159    fn drop(&mut self) {
160        let _ = unlinkat(&self.directory, self.name.as_str(), AtFlags::empty());
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::fs;
168    use std::path::Path;
169
170    fn request(folder: &Path, path: &Path, replacement: &str) -> FileSaveRequest {
171        FileSaveRequest {
172            folder: folder.to_owned(),
173            path: path.to_owned(),
174            text: replacement.into(),
175            expected_version: text::read_preview(folder, path).version.unwrap(),
176            overwrite: false,
177        }
178    }
179
180    #[test]
181    fn replaced_parent_cannot_redirect_an_overwrite() {
182        let root = tempfile::tempdir().unwrap();
183        let parent = root.path().join("child");
184        let moved = root.path().join("moved");
185        fs::create_dir(&parent).unwrap();
186        let path = parent.join("file");
187        fs::write(&path, "original").unwrap();
188        let mut edit = request(root.path(), &path, "my edits");
189        edit.overwrite = true;
190        let pinned = access::open_file(root.path(), &parent, true).unwrap();
191        fs::rename(&parent, &moved).unwrap();
192        fs::create_dir(&parent).unwrap();
193        fs::write(&path, "replacement").unwrap();
194        assert!(matches!(
195            save_in_directory(&edit, pinned),
196            Err(FileError::Save(_))
197        ));
198        assert_eq!(fs::read_to_string(&path).unwrap(), "replacement");
199        assert_eq!(fs::read_to_string(moved.join("file")).unwrap(), "original");
200    }
201
202    #[test]
203    fn saves_atomically_and_preserves_bom_line_endings_and_permissions() {
204        let root = tempfile::tempdir().unwrap();
205        let path = root.path().join("script");
206        fs::write(&path, "\u{feff}first\r\n🌲\n").unwrap();
207        fs::set_permissions(&path, fs::Permissions::from_mode(0o751)).unwrap();
208        let mut edit = request(root.path(), &path, "edited\r\n🌲\n");
209        let FileSaveOutcome::Saved(saved) = save(&edit).unwrap() else {
210            panic!("save conflicted")
211        };
212        assert_eq!(fs::read_to_string(&path).unwrap(), "\u{feff}edited\r\n🌲\n");
213        assert_eq!(
214            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
215            0o751
216        );
217        assert_eq!(saved, text::read_preview(root.path(), &path));
218        assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1);
219        edit.expected_version = saved.version.unwrap();
220        edit.text = String::new();
221        assert!(matches!(save(&edit).unwrap(), FileSaveOutcome::Saved(_)));
222        assert_eq!(fs::read(&path).unwrap(), b"\xef\xbb\xbf");
223    }
224
225    #[test]
226    fn changed_replaced_and_deleted_files_conflict_until_explicit_overwrite() {
227        let root = tempfile::tempdir().unwrap();
228        let path = root.path().join("file");
229        for change in 0..3 {
230            fs::write(&path, "original").unwrap();
231            let mut edit = request(root.path(), &path, "my edits");
232            match change {
233                0 => fs::write(&path, "external").unwrap(), // Same size, immediate write.
234                1 => {
235                    let other = root.path().join("other");
236                    fs::write(&other, "original").unwrap(); // Even identical bytes have a new identity.
237                    fs::rename(other, &path).unwrap();
238                }
239                _ => fs::remove_file(&path).unwrap(),
240            }
241            let before = fs::read(&path).ok();
242            assert!(matches!(save(&edit).unwrap(), FileSaveOutcome::Conflict(_)));
243            assert_eq!(fs::read(&path).ok(), before);
244            edit.overwrite = true;
245            assert!(matches!(save(&edit).unwrap(), FileSaveOutcome::Saved(_)));
246            assert_eq!(fs::read_to_string(&path).unwrap(), "my edits");
247        }
248    }
249
250    #[test]
251    fn refuses_escape_links_special_files_and_oversize_edits() {
252        let root = tempfile::tempdir().unwrap();
253        let outside = tempfile::tempdir().unwrap();
254        let path = root.path().join("file");
255        let target = outside.path().join("target");
256        fs::write(&path, "inside").unwrap();
257        fs::write(&target, "outside").unwrap();
258        let mut edit = request(root.path(), &path, "edited");
259        edit.text = "a".repeat(usize::try_from(TEXT_LIMIT).unwrap() + 1);
260        assert!(matches!(save(&edit), Err(FileError::TextTooLarge)));
261        edit.text = "edit\0".into();
262        assert!(matches!(save(&edit), Err(FileError::UnsupportedSave)));
263        edit.text = "edit".into();
264        edit.path = target.clone();
265        assert!(matches!(save(&edit), Err(FileError::InvalidRequest)));
266        edit.path = path.clone();
267        fs::remove_file(&path).unwrap();
268        std::os::unix::fs::symlink(&target, &path).unwrap();
269        assert!(matches!(save(&edit).unwrap(), FileSaveOutcome::Conflict(_)));
270        edit.overwrite = true;
271        assert!(matches!(save(&edit), Err(FileError::UnsupportedSave)));
272        assert_eq!(fs::read_to_string(target).unwrap(), "outside");
273        fs::remove_file(&path).unwrap();
274        let _listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
275        assert!(matches!(save(&edit), Err(FileError::UnsupportedSave)));
276    }
277}