diff --git a/src/main/java/com/condation/cms/modules/backup/BackupLifecycleExtension.java b/src/main/java/com/condation/cms/modules/backup/BackupLifecycleExtension.java index ceb15d1..a6136ea 100644 --- a/src/main/java/com/condation/cms/modules/backup/BackupLifecycleExtension.java +++ b/src/main/java/com/condation/cms/modules/backup/BackupLifecycleExtension.java @@ -128,6 +128,23 @@ public void started() { log.debug("creating backup {} into {}", name, targetFile.getFileName().toString()); TarGzPacker.createTarGz(ServerUtil.getHome(), targetFile.toFile(), sources); + if (backup.isProcessOnlyOnChange()) { + Path checksumFile = targetPath.resolve(name + ".sha256"); + String newChecksum = BackupUtil.calculateSHA256(targetFile); + + if (Files.exists(checksumFile)) { + String oldChecksum = Files.readString(checksumFile); + if (oldChecksum.equals(newChecksum)) { + log.debug("backup {} has not changed, skipping post-processing and deleting new backup.", name); + Files.delete(targetFile); + return; // Skip post-processing + } + } + Path tempChecksumFile = Files.createTempFile(targetPath, name, ".sha256.tmp"); + Files.writeString(tempChecksumFile, newChecksum); + Files.move(tempChecksumFile, checksumFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } + var hookSystem = getContext().get(InjectorFeature.class).injector().getInstance(HookSystem.class); hookSystem.execute("module/backup/postprocess", Map.of( "file", targetFile.toString(), diff --git a/src/main/java/com/condation/cms/modules/backup/BackupUtil.java b/src/main/java/com/condation/cms/modules/backup/BackupUtil.java index 38ccc62..a595f01 100644 --- a/src/main/java/com/condation/cms/modules/backup/BackupUtil.java +++ b/src/main/java/com/condation/cms/modules/backup/BackupUtil.java @@ -1,5 +1,3 @@ -package com.condation.cms.modules.backup; - /*- * #%L * backup-module @@ -21,65 +19,39 @@ * . * #L% */ +package com.condation.cms.modules.backup; -import org.apache.commons.compress.archivers.tar.TarArchiveEntry; -import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; -import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; - -import java.io.*; -import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; public class BackupUtil { - /** - * Creates a tar.gz backup of the specified path while ignoring the temp/ - * folder. - * - * @param sourceDir the path to the source directory - * @param targetFile the target file for the resulting tar.gz - * @throws IOException if any read or write errors occur - */ - public static void createTarGzBackup(Path sourceDir, Path targetFile) throws IOException { - if (!Files.isDirectory(sourceDir)) { - throw new IllegalArgumentException("sourceDir muss ein Ordner sein"); - } - - try (OutputStream fOut = Files.newOutputStream(targetFile); BufferedOutputStream buffOut = new BufferedOutputStream(fOut); GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(buffOut); TarArchiveOutputStream tarOut = new TarArchiveOutputStream(gzOut)) { - - tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); - - Files.walkFileTree(sourceDir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - // temp/ Ordner ignorieren - if (dir.getFileName().toString().equalsIgnoreCase("temp")) { - return FileVisitResult.SKIP_SUBTREE; - } - if (!sourceDir.equals(dir)) { - // Relativer Pfad - Path relativePath = sourceDir.relativize(dir); - TarArchiveEntry entry = new TarArchiveEntry(dir.toFile(), relativePath.toString() + "/"); - tarOut.putArchiveEntry(entry); - tarOut.closeArchiveEntry(); - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - // Datei schreiben - Path relativePath = sourceDir.relativize(file); - TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), relativePath.toString()); - entry.setSize(Files.size(file)); - tarOut.putArchiveEntry(entry); - Files.copy(file, tarOut); - tarOut.closeArchiveEntry(); - return FileVisitResult.CONTINUE; - } - }); - - tarOut.finish(); - } - } + public static String calculateSHA256(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream is = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + byte[] encodedhash = digest.digest(); + return bytesToHex(encodedhash); + } + + private static String bytesToHex(byte[] hash) { + StringBuilder hexString = new StringBuilder(2 * hash.length); + for (int i = 0; i < hash.length; i++) { + String hex = Integer.toHexString(0xff & hash[i]); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } } diff --git a/src/main/java/com/condation/cms/modules/backup/Configuration.java b/src/main/java/com/condation/cms/modules/backup/Configuration.java index 983e03b..f9e1832 100644 --- a/src/main/java/com/condation/cms/modules/backup/Configuration.java +++ b/src/main/java/com/condation/cms/modules/backup/Configuration.java @@ -42,6 +42,7 @@ public static class Backup { private String name; private boolean enabled = false; + private boolean processOnlyOnChange = false; private List include_files; private List include_dirs; private List post_processing; diff --git a/src/main/java/com/condation/cms/modules/backup/TarGzPacker.java b/src/main/java/com/condation/cms/modules/backup/TarGzPacker.java index 5734c7f..e141f71 100644 --- a/src/main/java/com/condation/cms/modules/backup/TarGzPacker.java +++ b/src/main/java/com/condation/cms/modules/backup/TarGzPacker.java @@ -22,66 +22,50 @@ * #L% */ -import com.condation.cms.api.utils.ServerUtil; -import java.io.BufferedOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; -import org.apache.commons.io.IOUtils; +import org.apache.commons.compress.utils.IOUtils; -/** - * - * @author thmar - */ public class TarGzPacker { - - public static void createTarGz(Path root, File output, List sources) throws IOException { - Path rootPath = root.toAbsolutePath().normalize(); - - try (FileOutputStream fos = new FileOutputStream(output); - BufferedOutputStream bos = new BufferedOutputStream(fos); - GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(bos); + public static void createTarGz(Path basePath, File outputFile, List sources) throws IOException { + try (FileOutputStream fos = new FileOutputStream(outputFile); + GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(fos); TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos)) { - taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); - - for (Path source : sources) { - Path sourcePath = source.toAbsolutePath().normalize(); - if (!sourcePath.startsWith(rootPath)) { - throw new IllegalArgumentException("source directory not inside server home: " + source); + for (Path source : sources) { + if (Files.isDirectory(source)) { + try (Stream stream = Files.walk(source)) { + stream.filter(p -> !Files.isDirectory(p)).forEach(p -> { + try { + addFileToTar(basePath, p, taos); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } else { + addFileToTar(basePath, source, taos); } - addFileToTarGz(taos, sourcePath, rootPath); } } } - private static void addFileToTarGz(TarArchiveOutputStream taos, Path path, Path root) throws IOException { - Path relativePath = root.relativize(path); - String entryName = relativePath.toString().replace("\\", "/"); - - TarArchiveEntry entry = new TarArchiveEntry(path.toFile(), entryName); + private static void addFileToTar(Path basePath, Path file, TarArchiveOutputStream taos) throws IOException { + String relativePath = basePath.relativize(file).toString(); + TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), relativePath); taos.putArchiveEntry(entry); - - if (Files.isRegularFile(path)) { - try (InputStream is = Files.newInputStream(path)) { - IOUtils.copy(is, taos); - } - taos.closeArchiveEntry(); - } else if (Files.isDirectory(path)) { - taos.closeArchiveEntry(); - try (DirectoryStream stream = Files.newDirectoryStream(path)) { - for (Path child : stream) { - addFileToTarGz(taos, child, root); - } - } + try (FileInputStream fis = new FileInputStream(file.toFile())) { + IOUtils.copy(fis, taos); } + taos.closeArchiveEntry(); } } diff --git a/src/test/java/com/condation/cms/modules/backup/BackupLifecycleExtensionTest.java b/src/test/java/com/condation/cms/modules/backup/BackupLifecycleExtensionTest.java new file mode 100644 index 0000000..7758ecd --- /dev/null +++ b/src/test/java/com/condation/cms/modules/backup/BackupLifecycleExtensionTest.java @@ -0,0 +1,169 @@ +package com.condation.cms.modules.backup; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.extensions.server.ServerLifecycleExtensionPoint; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.scheduler.CronJobScheduler; +import com.condation.cms.api.scheduler.ScheduledTask; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.api.utils.ServerUtil; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.name.Names; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +public class BackupLifecycleExtensionTest { + + private BackupLifecycleExtension extension; + private CronJobScheduler scheduler; + private HookSystem hookSystem; + private Path tempDir; + private Context context; + private InjectorFeature injectorFeature; + private Injector injector; + + @BeforeEach + public void setUp(@TempDir Path tempDir) throws Exception { + this.tempDir = tempDir; + + // Mocks + scheduler = mock(CronJobScheduler.class); + hookSystem = mock(HookSystem.class); + context = mock(ServerLifecycleExtensionPoint.Context.class); + injectorFeature = mock(InjectorFeature.class); + injector = mock(Injector.class); + + // Stubbing + when(context.get(InjectorFeature.class)).thenReturn(injectorFeature); + when(injectorFeature.injector()).thenReturn(injector); + when(injector.getInstance(Key.get(CronJobScheduler.class, Names.named("server")))).thenReturn(scheduler); + when(injector.getInstance(HookSystem.class)).thenReturn(hookSystem); + + // Class under test + extension = new BackupLifecycleExtension(); + + // Inject mock context using reflection + Field contextField = ServerLifecycleExtensionPoint.class.getDeclaredField("context"); + contextField.setAccessible(true); + contextField.set(extension, context); + } + + private void runBackup(Configuration config) { + try (MockedStatic mockedConfigLoader = mockStatic(ConfigLoader.class); + MockedStatic mockedServerUtil = mockStatic(ServerUtil.class); + MockedStatic mockedPathUtil = mockStatic(PathUtil.class)) { + + mockedConfigLoader.when(ConfigLoader::load).thenReturn(Optional.of(config)); + mockedServerUtil.when(ServerUtil::getHome).thenReturn(tempDir); + mockedPathUtil.when(() -> PathUtil.isChild(any(), any())).thenReturn(true); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(ScheduledTask.class); + extension.started(); + verify(scheduler).schedule(anyString(), anyString(), taskCaptor.capture()); + taskCaptor.getValue().execute(null); + } + } + + @Test + public void testBackupSkipsPostProcessingWhenNoChanges() throws IOException, NoSuchAlgorithmException { + // Arrange + Path sourceFile = Files.createFile(tempDir.resolve("source.txt")); + Files.writeString(sourceFile, "This is a test file."); + + Path tempBackupFile = tempDir.resolve("temp.tar.gz"); + TarGzPacker.createTarGz(tempDir, tempBackupFile.toFile(), Collections.singletonList(sourceFile)); + String checksum = BackupUtil.calculateSHA256(tempBackupFile); + Files.delete(tempBackupFile); + + Path checksumFile = tempDir.resolve("testBackup.sha256"); + Files.writeString(checksumFile, checksum); + + Configuration.Backup backupConfig = new Configuration.Backup(); + backupConfig.setName("testBackup"); + backupConfig.setEnabled(true); + backupConfig.setProcessOnlyOnChange(true); + backupConfig.setTarget(tempDir.toString()); + backupConfig.setCron("0 0 * * *"); + backupConfig.setInclude_files(Collections.singletonList(sourceFile.toString())); + + Configuration config = new Configuration(); + config.setBackups(Collections.singletonList(backupConfig)); + + // Act + runBackup(config); + + // Assert + verify(hookSystem, never()).execute(anyString(), any(Map.class)); + try (Stream files = Files.list(tempDir)) { + long backupFileCount = files.filter(p -> p.toString().endsWith(".tar.gz")).count(); + assertEquals(0, backupFileCount, "No backup file should exist as it should be deleted."); + } + } + + @Test + public void testBackupRunsPostProcessingWhenChanges() throws IOException, NoSuchAlgorithmException { + // Arrange + Path sourceFile = Files.createFile(tempDir.resolve("source.txt")); + Files.writeString(sourceFile, "This is a test file."); + + Path tempBackupFile = tempDir.resolve("temp.tar.gz"); + TarGzPacker.createTarGz(tempDir, tempBackupFile.toFile(), Collections.singletonList(sourceFile)); + String checksum = BackupUtil.calculateSHA256(tempBackupFile); + Files.delete(tempBackupFile); + + Path checksumFile = tempDir.resolve("testBackup.sha256"); + Files.writeString(checksumFile, checksum); + + // Change the source file so the new backup has a different checksum + Files.writeString(sourceFile, "This is a modified test file."); + + Configuration.Backup backupConfig = new Configuration.Backup(); + backupConfig.setName("testBackup"); + backupConfig.setEnabled(true); + backupConfig.setProcessOnlyOnChange(true); + backupConfig.setTarget(tempDir.toString()); + backupConfig.setCron("0 0 * * *"); + backupConfig.setInclude_files(Collections.singletonList(sourceFile.toString())); + + Configuration config = new Configuration(); + config.setBackups(Collections.singletonList(backupConfig)); + + // Act + runBackup(config); + + // Assert + verify(hookSystem, times(1)).execute(eq("module/backup/postprocess"), any(Map.class)); + try (Stream files = Files.list(tempDir)) { + long backupFileCount = files.filter(p -> p.toString().endsWith(".tar.gz")).count(); + assertEquals(1, backupFileCount, "A new backup file should exist."); + } + assertTrue(Files.exists(checksumFile)); + } +}