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 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
118struct 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(), 1 => {
235 let other = root.path().join("other");
236 fs::write(&other, "original").unwrap(); 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}