diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java new file mode 100644 index 0000000000..510ebb417a --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java @@ -0,0 +1,69 @@ +package sonia.scm.store; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +public final class CopyOnWrite { + + private CopyOnWrite() {} + + public static void withTemporaryFile(FileWriter creator, Path targetFile) { + validateInput(targetFile); + + try { + Path temporaryFile = Files.createFile(targetFile.getParent().resolve(UUID.randomUUID().toString())); + + creator.write(temporaryFile); + + replaceOriginalFile(targetFile, temporaryFile); + } catch (Exception ex) { + throw new StoreException("could not write file", ex); + } + } + + private static void validateInput(Path targetFile) { + if (Files.isDirectory(targetFile)) { + throw new IllegalArgumentException("target file has to be a regular file, not a directory"); + } + if (targetFile.getParent() == null) { + throw new IllegalArgumentException("target file has to be specified with a parent directory"); + } + } + + private static void replaceOriginalFile(Path targetFile, Path temporaryFile) throws IOException { + Path backupFile = backupOriginalFile(targetFile); + try { + Files.move(temporaryFile, targetFile); + if (backupFile != null) { + Files.delete(backupFile); + } + } catch (RuntimeException | IOException e) { + restoreBackup(targetFile, backupFile); + throw e; + } + } + + private static Path backupOriginalFile(Path targetFile) throws IOException { + Path directory = targetFile.getParent(); + if (Files.exists(targetFile)) { + Path backupFile = directory.resolve(UUID.randomUUID().toString()); + Files.move(targetFile, backupFile); + return backupFile; + } else { + return null; + } + } + + private static void restoreBackup(Path targetFile, Path backupFile) throws IOException { + if (backupFile != null) { + Files.move(backupFile, targetFile); + } + } + + @FunctionalInterface + public interface FileWriter { + void write(Path t) throws Exception; + } +} diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/CopyOnWriteTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/CopyOnWriteTest.java new file mode 100644 index 0000000000..338a491c0d --- /dev/null +++ b/scm-dao-xml/src/test/java/sonia/scm/store/CopyOnWriteTest.java @@ -0,0 +1,101 @@ +package sonia.scm.store; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static sonia.scm.store.CopyOnWrite.withTemporaryFile; + +@ExtendWith(TempDirectory.class) +class CopyOnWriteTest { + + @Test + void shouldCreateNewFile(@TempDirectory.TempDir Path tempDir) { + Path expectedFile = tempDir.resolve("toBeCreated.txt"); + + withTemporaryFile( + file -> new FileOutputStream(file.toFile()).write("great success".getBytes()), + expectedFile); + + Assertions.assertThat(expectedFile).hasContent("great success"); + } + + @Test + void shouldOverwriteExistingFile(@TempDirectory.TempDir Path tempDir) throws IOException { + Path expectedFile = tempDir.resolve("toBeOverwritten.txt"); + Files.createFile(expectedFile); + + withTemporaryFile( + file -> new FileOutputStream(file.toFile()).write("great success".getBytes()), + expectedFile); + + Assertions.assertThat(expectedFile).hasContent("great success"); + } + + @Test + void shouldFailForDirectory(@TempDirectory.TempDir Path tempDir) { + assertThrows(IllegalArgumentException.class, + () -> withTemporaryFile( + file -> new FileOutputStream(file.toFile()).write("should not be written".getBytes()), + tempDir)); + } + + @Test + void shouldFailForMissingDirectory() { + assertThrows( + IllegalArgumentException.class, + () -> withTemporaryFile( + file -> new FileOutputStream(file.toFile()).write("should not be written".getBytes()), + Paths.get("someFile"))); + } + + @Test + void shouldKeepBackupIfTemporaryFileCouldNotBeWritten(@TempDirectory.TempDir Path tempDir) throws IOException { + Path unchangedOriginalFile = tempDir.resolve("notToBeDeleted.txt"); + new FileOutputStream(unchangedOriginalFile.toFile()).write("this should be kept".getBytes()); + + assertThrows( + IOException.class, + () -> withTemporaryFile( + file -> { + throw new IOException("test"); + }, + unchangedOriginalFile)); + + Assertions.assertThat(unchangedOriginalFile).hasContent("this should be kept"); + } + + @Test + void shouldKeepBackupIfTemporaryFileIsMissing(@TempDirectory.TempDir Path tempDir) throws IOException { + Path backedUpFile = tempDir.resolve("notToBeDeleted.txt"); + new FileOutputStream(backedUpFile.toFile()).write("this should be kept".getBytes()); + + assertThrows( + IOException.class, + () -> withTemporaryFile( + Files::delete, + backedUpFile)); + + Assertions.assertThat(backedUpFile).hasContent("this should be kept"); + } + + @Test + void shouldDeleteExistingFile(@TempDirectory.TempDir Path tempDir) throws IOException { + Path expectedFile = tempDir.resolve("toBeReplaced.txt"); + new FileOutputStream(expectedFile.toFile()).write("this should be removed".getBytes()); + + withTemporaryFile( + file -> new FileOutputStream(file.toFile()).write("overwritten".getBytes()), + expectedFile); + + Assertions.assertThat(Files.list(tempDir)).hasSize(1); + } +}