From b508a3f6bb5c2d17e8ed6719fa04980ff9b471fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E9=A6=99=E7=AB=B9=E7=AD=8D?= Date: Thu, 23 Jul 2026 12:23:26 +0800 Subject: [PATCH] feat: add file replace option to backup-list path entries Allows a `path` backup-list entry to declare `replace: [{file, with}]` pairs, substituting a stand-in file (e.g. an exported copy of a locked database) for the original when zipping, while keeping the original file name inside the archive. Validates `file`/`with` against traversal and absolute paths, resolves symlinks to ensure replacement sources stay within the backup location, and skips replacements that would pull in the local backup storage folder itself. --- .../ratismal/drivebackup/UploadThread.java | 12 ++-- .../config/configSections/BackupList.java | 25 ++++++- .../ratismal/drivebackup/util/FileUtil.java | 70 +++++++++++++++++-- DriveBackup/src/main/resources/intl.yml | 6 ++ 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/UploadThread.java b/DriveBackup/src/main/java/ratismal/drivebackup/UploadThread.java index 1cb9b7b3..203d512a 100644 --- a/DriveBackup/src/main/java/ratismal/drivebackup/UploadThread.java +++ b/DriveBackup/src/main/java/ratismal/drivebackup/UploadThread.java @@ -257,7 +257,7 @@ void run_internal() { backupBackingUp++; for (Path folder : set.location.getPaths()) { if (set.create) { - makeBackupFile(folder.toString(), set.formatter, Arrays.asList(set.blacklist)); + makeBackupFile(folder.toString(), set.formatter, Arrays.asList(set.blacklist), set.replace); } } } @@ -381,11 +381,11 @@ private void pruneLocalBackups() { * @param formatter save format configuration * @param blackList a configured blacklist (with globs) */ - private void makeBackupFile(String location, LocalDateTimeFormatter formatter, List blackList) { + private void makeBackupFile(String location, LocalDateTimeFormatter formatter, List blackList, Map replace) { logger.info(intl("backup-local-file-start"), "location", location); try { ServerUtil.setAutoSave(false); - fileUtil.makeBackup(location, formatter, blackList); + fileUtil.makeBackup(location, formatter, blackList, replace); } catch (AbsolutePathException exception) { logger.log(intl("backup-failed-absolute-path")); return; @@ -524,7 +524,8 @@ private void makeExternalFileBackup(ExternalFTPSource externalBackup) { new PathBackupLocation("external-backups" + "/" + tempFolderName), externalBackup.format, true, - new String[0] + new String[0], + new HashMap<>() ); backupList.add(backup); if (ftpUploader.isErrorWhileUploading()) { @@ -569,7 +570,8 @@ private void makeExternalDatabaseBackup(ExternalMySQLSource externalBackup) { new PathBackupLocation("external-backups" + "/" + tempFolderName), externalBackup.format, true, - new String[0] + new String[0], + new HashMap<>() ); backupList.add(backup); if (mysqlUploader.isErrorWhileUploading()) { diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/config/configSections/BackupList.java b/DriveBackup/src/main/java/ratismal/drivebackup/config/configSections/BackupList.java index afcd2361..608e26fc 100644 --- a/DriveBackup/src/main/java/ratismal/drivebackup/config/configSections/BackupList.java +++ b/DriveBackup/src/main/java/ratismal/drivebackup/config/configSections/BackupList.java @@ -12,6 +12,8 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -63,18 +65,21 @@ public String toString() { public final LocalDateTimeFormatter formatter; public final boolean create; public final String[] blacklist; + public final Map replace; public BackupListEntry( BackupLocation location, LocalDateTimeFormatter formatter, boolean create, - String[] blacklist + String[] blacklist, + Map replace ) { this.location = location; this.formatter = formatter; this.create = create; this.blacklist = blacklist; + this.replace = replace; } } @@ -134,7 +139,23 @@ public static BackupList parse(@NotNull FileConfiguration config, Logger logger) logger.log(intl("backup-list-blacklist-invalid"), ENTRY, entryIndex); } } - list.add(new BackupListEntry(location, formatter, create, blacklist)); + Map replace = new LinkedHashMap<>(); + if (rawListEntry.containsKey("path") && rawListEntry.containsKey("replace")) { + try { + List> replaceList = (List>) rawListEntry.get("replace"); + for (Map replaceListEntry : replaceList) { + if (replaceListEntry.containsKey("file") && replaceListEntry.containsKey("with")) { + replace.put(replaceListEntry.get("file"), replaceListEntry.get("with")); + } else { + replace.clear(); + throw new IllegalArgumentException(); + } + } + } catch (IllegalArgumentException | ClassCastException e) { + logger.log(intl("backup-list-replace-invalid"), ENTRY, entryIndex); + } + } + list.add(new BackupListEntry(location, formatter, create, blacklist, replace)); } return new BackupList(list.toArray(new BackupListEntry[0])); } diff --git a/DriveBackup/src/main/java/ratismal/drivebackup/util/FileUtil.java b/DriveBackup/src/main/java/ratismal/drivebackup/util/FileUtil.java index 39cee006..dc1f58d0 100644 --- a/DriveBackup/src/main/java/ratismal/drivebackup/util/FileUtil.java +++ b/DriveBackup/src/main/java/ratismal/drivebackup/util/FileUtil.java @@ -19,7 +19,9 @@ import java.nio.file.attribute.BasicFileAttributes; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -70,7 +72,7 @@ public TreeMap getLocalBackups(String location, LocalDateTimeFormatt * @param blacklistGlobs a list of glob patterns of files/folders to not include in the backup. * @throws Exception */ - public void makeBackup(@NotNull String location, LocalDateTimeFormatter formatter, List blacklistGlobs) throws Exception { + public void makeBackup(@NotNull String location, LocalDateTimeFormatter formatter, List blacklistGlobs, Map replace) throws Exception { Config config = ConfigParser.getConfig(); if (location.charAt(0) == '/') { throw new AbsolutePathException("Location cannot start with a slash"); @@ -104,6 +106,41 @@ public void makeBackup(@NotNull String location, LocalDateTimeFormatter formatte "glob-pattern", globPattern); } } + Map checkedReplace = new HashMap<>(); + if (Files.isDirectory(Paths.get(location))) { + Map fileListPaths = fileList.getList().stream() + .collect(Collectors.toMap(Paths::get, s -> s, (a, b) -> a)); + replace.forEach((key, value) -> { + if (isUnsafeRelativePath(key) || isUnsafeRelativePath(value)) { + logger.info(intl("backup-list-replace-unsafe-path"), "file-path", key + " -> " + value); + return; + } + File file = new File(location + "/" + key); + File with = new File(location + "/" + value); + if (!isWithinLocation(location, with)) { + logger.info(intl("local-backup-replace-escapes-location"), "file-path", key + " -> " + value, "location", location); + return; + } + Path filePath = Paths.get(key); + Path withPath = Paths.get(value); + String matched = fileListPaths.get(filePath); + if (file.isFile() && with.isFile() && matched != null) { + if (isWithinLocation(config.backupStorage.localDirectory, with)) { + fileList.incFilesInBackupFolder(); + return; + } + if (checkedReplace.containsKey(matched)) { + logger.info(intl("local-backup-replace-duplicate"), "file-path", matched); + return; + } + checkedReplace.put(matched, withPath.toString()); + return; + } + logger.info(intl("local-backup-replace-skipped"), "file-path", file.getPath(), "with-path", with.getPath()); + }); + } else if (!replace.isEmpty()) { + logger.info(intl("local-backup-replace-not-folder"), "location", location); + } int filesInBackupFolder = fileList.getFilesInBackupFolder(); if (filesInBackupFolder > 0) { logger.info( @@ -115,7 +152,7 @@ public void makeBackup(@NotNull String location, LocalDateTimeFormatter formatte String lastFolderName = location.substring(lastSeparatorIndex + 1); fileName = fileName.replace(NAME_KEYWORD, lastFolderName); } - zipIt(location, path.getPath() + "/" + fileName, fileList); + zipIt(location, path.getPath() + "/" + fileName, fileList, checkedReplace); } /** @@ -175,7 +212,7 @@ public void pruneLocalBackups(String location, LocalDateTimeFormatter formatter) * @param outputFilePath the path of the folder to put it in * @param fileList file to include in the zip */ - private void zipIt(String inputFolderPath, String outputFilePath, BackupFileList fileList) throws Exception { + private void zipIt(String inputFolderPath, String outputFilePath, BackupFileList fileList, Map replace) throws Exception { byte[] buffer = new byte[1024]; FileOutputStream fileOutputStream; ZipOutputStream zipOutputStream = null; @@ -189,7 +226,7 @@ private void zipIt(String inputFolderPath, String outputFilePath, BackupFileList zipOutputStream.setLevel(ConfigParser.getConfig().backupStorage.zipCompression); for (String file : fileList.getList()) { ZipEntry entry = new ZipEntry(formattedInputFolderPath + "/" + file); - String filePath = inputFolderPath + "/" + file; + String filePath = inputFolderPath + "/" + replace.getOrDefault(file, file); BasicFileAttributes fileAttributes = null; try { fileAttributes = Files.readAttributes(Paths.get(filePath), BasicFileAttributes.class); @@ -324,6 +361,31 @@ private static String escapeBackupLocation(@NotNull String location) { return location.replace("../", ""); } + private static boolean isUnsafeRelativePath(@NotNull String path) { + if (path.isEmpty()) return true; + Path p = Paths.get(path); + if (p.isAbsolute()) return true; + for (Path seg : p) { + if (seg.toString().equals("..")) return true; + } + return false; + } + + /** + * Whether the target file's real (symlink-resolved) path is still contained within location. + * Guards against a symlink inside location pointing outside of it, which a purely + * string-based check like {@link #isUnsafeRelativePath} cannot detect. + */ + private static boolean isWithinLocation(String location, File target) { + try { + Path root = new File(location).getCanonicalFile().toPath(); + Path real = target.getCanonicalFile().toPath(); + return real.startsWith(root); + } catch (IOException e) { + return false; + } + } + /** * Finds all folders that match a glob * @param glob the glob to search diff --git a/DriveBackup/src/main/resources/intl.yml b/DriveBackup/src/main/resources/intl.yml index 889f54bb..ca1381df 100644 --- a/DriveBackup/src/main/resources/intl.yml +++ b/DriveBackup/src/main/resources/intl.yml @@ -17,6 +17,8 @@ backup-failed-absolute-path: |- backup-file-upload-complete: 'Upload(s) for file "" complete' backup-file-upload-start: 'Starting upload(s) for file ""' backup-forced: "Forcing a backup" +backup-list-replace-invalid: "Replace invalid in backup entry , leaving blank" +backup-list-replace-unsafe-path: 'Skipping unsafe replace path "", it must be a relative path without ".."' backup-list-blacklist-invalid: "Blacklist invalid in backup entry , leaving blank" backup-list-format-invalid: "Format invalid, skipping backup list entry " backup-list-glob-invalid: "Glob invalid, skipping backup list entry " @@ -198,6 +200,10 @@ local-backup-limit-reached: "There are file(s) which exceeds the local-backup-no-limit: "Local backup limit is set to 0, skipping pruning" local-backup-pruning-complete: 'Local backup pruning complete for ""' local-backup-pruning-start: 'Pruning local backups for ""' +local-backup-replace-duplicate: 'Multiple replace entries target "" in the backup, using the first one' +local-backup-replace-escapes-location: 'Skipping replace of "", it resolves outside of location "" (possibly via a symlink)' +local-backup-replace-not-folder: 'Ignoring replace for "", it must be a folder to use replace' +local-backup-replace-skipped: 'Skipping replace of "" with "", make sure both files exist and "" is not blacklisted' local-keep-count-invalid: "Inputted local keep count invalid, using default" local-save-directory-not-relative: "Local save directory is not relative, making relative to server directory" location-empty: "Location is empty, skipping"