1use crate::RunnerIoError;
2use fs_err as fs;
3use std::path::{Path, PathBuf};
4
5#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
7pub struct FileMutation {
8 path: PathBuf,
9 original: Option<Vec<u8>>,
10 replacement: Option<Vec<u8>>,
11}
12
13impl FileMutation {
14 pub fn path(&self) -> &Path {
15 &self.path
16 }
17
18 pub fn original(&self) -> Option<&[u8]> {
19 self.original.as_deref()
20 }
21
22 pub fn replacement(&self) -> Option<&[u8]> {
23 self.replacement.as_deref()
24 }
25}
26
27#[derive(Clone, Debug, Default, serde::Deserialize, Eq, PartialEq, serde::Serialize)]
29pub struct FileTransaction {
30 mutations: Vec<FileMutation>,
31 #[serde(default)]
32 create_directories: Vec<PathBuf>,
33 #[serde(default)]
34 remove_empty_directories: Vec<PathBuf>,
35}
36
37impl FileTransaction {
38 pub fn is_empty(&self) -> bool {
39 self.mutations.is_empty()
40 && self.create_directories.is_empty()
41 && self.remove_empty_directories.is_empty()
42 }
43
44 pub fn len(&self) -> usize {
45 self.mutations.len() + self.create_directories.len() + self.remove_empty_directories.len()
46 }
47
48 pub fn mutations(&self) -> &[FileMutation] {
49 &self.mutations
50 }
51
52 pub fn plan_write(
53 &mut self,
54 path: impl Into<PathBuf>,
55 replacement: impl Into<Vec<u8>>,
56 ) -> Result<bool, RunnerIoError> {
57 let path = path.into();
58 let original = read_optional_file(&path)?;
59 self.plan_write_from(path, original, replacement)
60 }
61
62 #[doc(hidden)]
63 pub fn plan_write_from(
64 &mut self,
65 path: impl Into<PathBuf>,
66 original: Option<Vec<u8>>,
67 replacement: impl Into<Vec<u8>>,
68 ) -> Result<bool, RunnerIoError> {
69 let path = path.into();
70 let replacement = Some(replacement.into());
71 if original == replacement {
72 return Ok(false);
73 }
74
75 self.add_mutation(FileMutation {
76 path,
77 original,
78 replacement,
79 })?;
80 Ok(true)
81 }
82
83 pub fn plan_remove(&mut self, path: impl Into<PathBuf>) -> Result<bool, RunnerIoError> {
84 let path = path.into();
85 let Some(original) = read_optional_file(&path)? else {
86 return Ok(false);
87 };
88
89 self.add_mutation(FileMutation {
90 path,
91 original: Some(original),
92 replacement: None,
93 })?;
94 Ok(true)
95 }
96
97 pub fn plan_create_directory(
98 &mut self,
99 path: impl Into<PathBuf>,
100 ) -> Result<bool, RunnerIoError> {
101 let path = path.into();
102 match fs::symlink_metadata(&path) {
103 Ok(metadata) if metadata.is_dir() => return Ok(false),
104 Ok(_) => {
105 return Err(std::io::Error::new(
106 std::io::ErrorKind::AlreadyExists,
107 format!(
108 "cannot create directory because the path already exists: {}",
109 path.display()
110 ),
111 )
112 .into());
113 },
114 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
115 Err(error) => return Err(error.into()),
116 }
117
118 Ok(self.add_created_directory(path))
119 }
120
121 pub fn plan_remove_empty_directory(&mut self, path: impl Into<PathBuf>) {
122 let path = path.into();
123 if !self
124 .remove_empty_directories
125 .iter()
126 .any(|planned| planned == &path)
127 {
128 self.remove_empty_directories.push(path);
129 }
130 }
131
132 pub fn extend(&mut self, other: Self) -> Result<(), RunnerIoError> {
133 for mutation in other.mutations {
134 self.add_mutation(mutation)?;
135 }
136 for directory in other.create_directories {
137 self.add_created_directory(directory);
138 }
139 for directory in other.remove_empty_directories {
140 self.plan_remove_empty_directory(directory);
141 }
142 Ok(())
143 }
144
145 pub fn commit(&self) -> Result<bool, RunnerIoError> {
150 self.validate()?;
151 if self.is_empty() {
152 return Ok(false);
153 }
154
155 let mut created_directories = Vec::new();
156 for directory in &self.create_directories {
157 if let Err(error) = create_parent_directories(directory, &mut created_directories) {
158 return Err(rollback_error(error, &[], &[], &created_directories));
159 }
160 }
161
162 for (index, mutation) in self.mutations.iter().enumerate() {
163 if let Err(error) = apply_mutation(mutation, &mut created_directories) {
164 return Err(rollback_error(
165 error,
166 &self.mutations[..=index],
167 &[],
168 &created_directories,
169 ));
170 }
171 }
172
173 let mut directories = self.remove_empty_directories.clone();
174 directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
175 let mut removed_directories = Vec::new();
176 for directory in directories {
177 match remove_directory_if_empty(&directory) {
178 Ok(true) => removed_directories.push(directory),
179 Ok(false) => {},
180 Err(error) => {
181 return Err(rollback_error(
182 error,
183 &self.mutations,
184 &removed_directories,
185 &created_directories,
186 ));
187 },
188 }
189 }
190
191 Ok(true)
192 }
193
194 fn add_mutation(&mut self, mutation: FileMutation) -> Result<(), RunnerIoError> {
195 let Some(existing_index) = self
196 .mutations
197 .iter()
198 .position(|existing| existing.path == mutation.path)
199 else {
200 self.mutations.push(mutation);
201 return Ok(());
202 };
203
204 let existing = &mut self.mutations[existing_index];
205 if existing == &mutation {
206 return Ok(());
207 }
208 if existing.replacement == mutation.original {
209 existing.replacement = mutation.replacement;
210 if existing.original == existing.replacement {
211 self.mutations.remove(existing_index);
212 }
213 return Ok(());
214 }
215
216 Err(RunnerIoError::TransactionConflict {
217 path: mutation.path,
218 })
219 }
220
221 fn add_created_directory(&mut self, path: PathBuf) -> bool {
222 if self
223 .create_directories
224 .iter()
225 .any(|planned| planned == &path)
226 {
227 return false;
228 }
229 self.create_directories.push(path);
230 true
231 }
232
233 fn validate(&self) -> Result<(), RunnerIoError> {
234 for directory in &self.create_directories {
235 if self
236 .mutations
237 .iter()
238 .any(|mutation| mutation.path.as_path() == directory)
239 || self
240 .remove_empty_directories
241 .iter()
242 .any(|removed| removed == directory)
243 {
244 return Err(RunnerIoError::TransactionConflict {
245 path: directory.clone(),
246 });
247 }
248
249 match fs::symlink_metadata(directory) {
250 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
251 Ok(_) => {
252 return Err(RunnerIoError::TransactionChanged {
253 path: directory.clone(),
254 });
255 },
256 Err(error) => return Err(error.into()),
257 }
258 }
259
260 for mutation in &self.mutations {
261 if mutation.original == mutation.replacement {
262 return Err(RunnerIoError::InvalidRunnerRequest(format!(
263 "transaction contains an unchanged mutation for {}",
264 mutation.path.display()
265 )));
266 }
267
268 let current = read_optional_file(&mutation.path)?;
269 if current != mutation.original {
270 return Err(RunnerIoError::TransactionChanged {
271 path: mutation.path.clone(),
272 });
273 }
274 }
275 Ok(())
276 }
277}
278
279fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>, RunnerIoError> {
280 match fs::read(path) {
281 Ok(contents) => Ok(Some(contents)),
282 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
283 Err(error) => Err(error.into()),
284 }
285}
286
287fn apply_mutation(
288 mutation: &FileMutation,
289 created_directories: &mut Vec<PathBuf>,
290) -> std::io::Result<()> {
291 match &mutation.replacement {
292 Some(replacement) => {
293 if let Some(parent) = mutation.path.parent() {
294 create_parent_directories(parent, created_directories)?;
295 }
296 fs::write(&mutation.path, replacement)
297 },
298 None => fs::remove_file(&mutation.path),
299 }
300}
301
302fn create_parent_directories(
303 parent: &Path,
304 created_directories: &mut Vec<PathBuf>,
305) -> std::io::Result<()> {
306 if parent.as_os_str().is_empty() {
307 return Ok(());
308 }
309
310 let mut missing = Vec::new();
311 for ancestor in parent.ancestors() {
312 if ancestor.exists() {
313 break;
314 }
315 missing.push(ancestor.to_path_buf());
316 }
317
318 missing.reverse();
319 for directory in missing {
320 fs::create_dir(&directory)?;
321 created_directories.push(directory);
322 }
323 Ok(())
324}
325
326fn remove_directory_if_empty(path: &Path) -> std::io::Result<bool> {
327 match fs::read_dir(path) {
328 Ok(mut entries) => {
329 if entries.next().is_some() {
330 return Ok(false);
331 }
332 fs::remove_dir(path)?;
333 Ok(true)
334 },
335 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
336 Err(error) => Err(error),
337 }
338}
339
340fn rollback_error(
341 commit_error: std::io::Error,
342 applied_mutations: &[FileMutation],
343 removed_directories: &[PathBuf],
344 created_directories: &[PathBuf],
345) -> RunnerIoError {
346 let rollback = rollback(applied_mutations, removed_directories, created_directories);
347 match rollback {
348 Ok(()) => RunnerIoError::TransactionCommit(commit_error.to_string()),
349 Err(rollback_error) => RunnerIoError::TransactionRollback {
350 commit_error: commit_error.to_string(),
351 rollback_error: rollback_error.to_string(),
352 },
353 }
354}
355
356fn rollback(
357 applied_mutations: &[FileMutation],
358 removed_directories: &[PathBuf],
359 created_directories: &[PathBuf],
360) -> std::io::Result<()> {
361 for directory in removed_directories.iter().rev() {
362 fs::create_dir_all(directory)?;
363 }
364
365 for mutation in applied_mutations.iter().rev() {
366 match &mutation.original {
367 Some(original) => {
368 if let Some(parent) = mutation.path.parent() {
369 fs::create_dir_all(parent)?;
370 }
371 fs::write(&mutation.path, original)?;
372 },
373 None => match fs::remove_file(&mutation.path) {
374 Ok(()) => {},
375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
376 Err(error) => return Err(error),
377 },
378 }
379 }
380
381 let mut created_directories = created_directories.to_vec();
382 created_directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
383 for directory in created_directories {
384 match fs::remove_dir(&directory) {
385 Ok(()) => {},
386 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
387 Err(error) => return Err(error),
388 }
389 }
390
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn transaction_round_trips_through_json() {
400 let temp = tempfile::tempdir().expect("tempdir");
401 let path = temp.path().join("demo.ftl");
402 fs::write(&path, "old = Old\n").expect("write original");
403 let mut transaction = FileTransaction::default();
404 transaction
405 .plan_write(&path, b"new = New\n".to_vec())
406 .expect("plan write");
407 transaction
408 .plan_create_directory(temp.path().join("empty-locale"))
409 .expect("plan directory");
410
411 let encoded = serde_json::to_string(&transaction).expect("encode");
412 let decoded: FileTransaction = serde_json::from_str(&encoded).expect("decode");
413
414 assert_eq!(decoded, transaction);
415 }
416
417 #[test]
418 fn transaction_creates_an_empty_directory() {
419 let temp = tempfile::tempdir().expect("tempdir");
420 let directory = temp.path().join("empty-locale");
421 let mut transaction = FileTransaction::default();
422 transaction
423 .plan_create_directory(&directory)
424 .expect("plan directory");
425
426 assert!(transaction.commit().expect("commit"));
427 assert!(directory.is_dir());
428 }
429
430 #[test]
431 fn transaction_rejects_a_directory_created_after_planning() {
432 let temp = tempfile::tempdir().expect("tempdir");
433 let directory = temp.path().join("empty-locale");
434 let mut transaction = FileTransaction::default();
435 transaction
436 .plan_create_directory(&directory)
437 .expect("plan directory");
438 fs::create_dir(&directory).expect("external directory creation");
439
440 let error = transaction.commit().expect_err("commit should fail");
441
442 assert!(matches!(
443 error,
444 RunnerIoError::TransactionChanged { path: changed } if changed == directory
445 ));
446 assert!(directory.is_dir());
447 }
448
449 #[test]
450 fn transaction_rejects_a_file_and_directory_at_the_same_path() {
451 let temp = tempfile::tempdir().expect("tempdir");
452 let path = temp.path().join("conflict");
453 let mut transaction = FileTransaction::default();
454 transaction
455 .plan_create_directory(&path)
456 .expect("plan directory");
457 transaction
458 .plan_write(&path, b"message = Message\n".to_vec())
459 .expect("plan file");
460
461 let error = transaction.commit().expect_err("commit should fail");
462
463 assert!(matches!(
464 error,
465 RunnerIoError::TransactionConflict { path: conflict } if conflict == path
466 ));
467 assert!(!path.exists());
468 }
469
470 #[test]
471 fn transaction_extend_preserves_a_directory_before_state() {
472 let temp = tempfile::tempdir().expect("tempdir");
473 let directory = temp.path().join("empty-locale");
474 let mut planned = FileTransaction::default();
475 planned
476 .plan_create_directory(&directory)
477 .expect("plan directory");
478 let mut transaction = FileTransaction::default();
479 transaction.extend(planned).expect("extend transaction");
480 fs::create_dir(&directory).expect("external directory creation");
481
482 let error = transaction.commit().expect_err("commit should fail");
483
484 assert!(matches!(
485 error,
486 RunnerIoError::TransactionChanged { path: changed } if changed == directory
487 ));
488 assert!(directory.is_dir());
489 }
490
491 #[test]
492 fn transaction_rejects_files_changed_after_planning() {
493 let temp = tempfile::tempdir().expect("tempdir");
494 let path = temp.path().join("demo.ftl");
495 fs::write(&path, "old = Old\n").expect("write original");
496 let mut transaction = FileTransaction::default();
497 transaction
498 .plan_write(&path, b"new = New\n".to_vec())
499 .expect("plan write");
500 fs::write(&path, "external = Edit\n").expect("write external edit");
501
502 let error = transaction.commit().expect_err("commit should fail");
503
504 assert!(matches!(
505 error,
506 RunnerIoError::TransactionChanged { path: changed } if changed == path
507 ));
508 assert_eq!(
509 fs::read_to_string(&path).expect("read external edit"),
510 "external = Edit\n"
511 );
512 }
513
514 #[test]
515 #[cfg(unix)]
516 fn transaction_rolls_back_when_a_later_write_fails() {
517 use std::os::unix::fs::PermissionsExt as _;
518
519 let temp = tempfile::tempdir().expect("tempdir");
520 let first = temp.path().join("first.ftl");
521 let blocked_parent = temp.path().join("blocked");
522 fs::create_dir(&blocked_parent).expect("create blocked directory");
523 let second = blocked_parent.join("second.ftl");
524
525 let mut transaction = FileTransaction::default();
526 transaction
527 .plan_write(&first, b"first = First\n".to_vec())
528 .expect("plan first");
529 transaction
530 .plan_write(&second, b"second = Second\n".to_vec())
531 .expect("plan second");
532 fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
533 .expect("make blocked directory read-only");
534
535 let error = transaction.commit().expect_err("commit should fail");
536
537 fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o755))
538 .expect("restore blocked directory permissions");
539 assert!(matches!(error, RunnerIoError::TransactionCommit(_)));
540 assert!(!first.exists(), "the first write must be rolled back");
541 assert!(!second.exists());
542 }
543
544 #[test]
545 #[cfg(unix)]
546 fn transaction_rolls_back_an_empty_directory_when_a_later_write_fails() {
547 use std::os::unix::fs::PermissionsExt as _;
548
549 let temp = tempfile::tempdir().expect("tempdir");
550 let empty_locale = temp.path().join("empty-locale");
551 let blocked_parent = temp.path().join("blocked");
552 fs::create_dir(&blocked_parent).expect("create blocked directory");
553 let blocked_file = blocked_parent.join("message.ftl");
554
555 let mut transaction = FileTransaction::default();
556 transaction
557 .plan_create_directory(&empty_locale)
558 .expect("plan directory");
559 transaction
560 .plan_write(&blocked_file, b"message = Message\n".to_vec())
561 .expect("plan file");
562 fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
563 .expect("make blocked directory read-only");
564
565 let error = transaction.commit().expect_err("commit should fail");
566
567 fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o755))
568 .expect("restore blocked directory permissions");
569 assert!(matches!(error, RunnerIoError::TransactionCommit(_)));
570 assert!(
571 !empty_locale.exists(),
572 "the empty locale must be rolled back"
573 );
574 assert!(!blocked_file.exists());
575 }
576
577 #[test]
578 fn transaction_merges_sequential_mutations_for_one_path() {
579 let temp = tempfile::tempdir().expect("tempdir");
580 let path = temp.path().join("demo.ftl");
581 fs::write(&path, "one").expect("write original");
582
583 let mut transaction = FileTransaction::default();
584 transaction
585 .add_mutation(FileMutation {
586 path: path.clone(),
587 original: Some(b"one".to_vec()),
588 replacement: Some(b"two".to_vec()),
589 })
590 .expect("add first");
591 transaction
592 .add_mutation(FileMutation {
593 path: path.clone(),
594 original: Some(b"two".to_vec()),
595 replacement: Some(b"three".to_vec()),
596 })
597 .expect("add second");
598
599 transaction.commit().expect("commit");
600 assert_eq!(fs::read_to_string(path).expect("read final"), "three");
601 }
602}