/* * Syncany, www.syncany.org * Copyright (C) 2011-2015 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.database; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.DosFileAttributes; import java.nio.file.attribute.PosixFileAttributes; import java.nio.file.attribute.PosixFilePermissions; import java.security.NoSuchAlgorithmException; import java.util.Date; import java.util.HashSet; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.syncany.database.FileContent.FileChecksum; import org.syncany.database.FileVersion.FileStatus; import org.syncany.database.FileVersion.FileType; import org.syncany.util.EnvironmentUtil; import org.syncany.util.FileUtil; /** * The file version comparator is a helper class to compare {@link FileVersion}s with each * other, or compare {@link FileVersion}s to local {@link File}s. * * <p>It captures the {@link FileProperties} of two files or file versions and compares them * using the various <tt>compare*</tt>-methods. A comparison returns a set of {@link FileChange}s, * each of which identifies a certain attribute change (e.g. checksum changed, name changed). * A file can be considered equal if the returned set of {@link FileChange}s is empty. * * <p>The file version comparator distinguishes between <i>cancelling</i> tests and regular tests. * Cancelling tests are implemented in {@link #performCancellingTests(FileVersionComparison) performCancellingTests()}. * They represent significant changes in a file, for which further comparison would not make * sense (e.g. new vs. deleted files or files vs. folders). If a cancelling test is not successful, * other tests are not performed. * * @author Philipp C. Heckel <philipp.heckel@gmail.com> */ public class FileVersionComparator { private static final Logger logger = Logger.getLogger(FileVersionComparator.class.getSimpleName()); private File rootFolder; private String checksumAlgorithm; /** * Creates a new file version comparator helper class. * * <p>The <tt>rootFolder</tt> is needed to allow a comparison of the relative file path. * The <tt>checksumAlgorithm</tt> is used for calculate and compare file checksums. Both * are used if a local {@link File} is compared to a {@link FileVersion}. * * @param rootFolder Base folder to determine a relative path to * @param checksumAlgorithm Digest algorithm for checksum calculation, e.g. "SHA1" or "MD5" */ public FileVersionComparator(File rootFolder, String checksumAlgorithm) { this.rootFolder = rootFolder; this.checksumAlgorithm = checksumAlgorithm; } /** * Compares two {@link FileVersion}s to each other and returns a {@link FileVersionComparison} object. * * @param expectedFileVersion The expected file version (that is compared to the actual file version) * @param actualFileVersion The actual file version (that is compared to the expected file version) * @return Returns a file version comparison object, indicating if there are differences between the file versions */ public FileVersionComparison compare(FileVersion expectedFileVersion, FileVersion actualFileVersion) { FileProperties expectedFileProperties = captureFileProperties(expectedFileVersion); FileProperties actualFileProperties = captureFileProperties(actualFileVersion); return compare(expectedFileProperties, actualFileProperties, true); } /** * Compares a {@link FileVersion} with a local {@link File} and returns a {@link FileVersionComparison} object. * * <p>If the actual file does not differ in size, it is necessary to calculate and compare the checksum of the * local file to the file version to reliably determine if it has changed. Unless comparing the size and last * modified date is enough, the <tt>actualFileForceChecksum</tt> parameter must be switched to <tt>true</tt>. * * @param expectedFileVersion The expected file version (that is compared to the actual file) * @param actualFile The actual file (that is compared to the expected file version) * @param actualFileForceChecksum Force a checksum comparison if necessary (if size does not differ) * @return Returns a file version comparison object, indicating if there are differences between the file versions */ public FileVersionComparison compare(FileVersion expectedFileVersion, File actualFile, boolean actualFileForceChecksum) { return compare(expectedFileVersion, actualFile, null, actualFileForceChecksum); } /** * Compares a {@link FileVersion} with a local {@link File} and returns a {@link FileVersionComparison} object. * * <p>If the actual file does not differ in size, it is necessary to calculate and compare the checksum of the * local file to the file version to reliably determine if it has changed. Unless comparing the size and last * modified date is enough, the <tt>actualFileForceChecksum</tt> parameter must be switched to <tt>true</tt>. * * <p>If the <tt>actualFileKnownChecksum</tt> parameter is set and a checksum comparison is necessary, this * parameter is used to compare checksums. If not and force checksum is enabled, the checksum is calculated * and compared. * * @param expectedFileVersion The expected file version (that is compared to the actual file) * @param actualFile The actual file (that is compared to the expected file version) * @param actualFileKnownChecksum If the checksum of the local file is known, it can be set * @param actualFileForceChecksum Force a checksum comparison if necessary (if size does not differ) * @return Returns a file version comparison object, indicating if there are differences between the file versions */ public FileVersionComparison compare(FileVersion expectedLocalFileVersion, File actualLocalFile, FileChecksum actualFileKnownChecksum, boolean actualFileForceChecksum) { FileProperties expectedLocalFileVersionProperties = captureFileProperties(expectedLocalFileVersion); FileProperties actualFileProperties = captureFileProperties(actualLocalFile, actualFileKnownChecksum, actualFileForceChecksum); return compare(expectedLocalFileVersionProperties, actualFileProperties, actualFileForceChecksum); } public FileVersionComparison compare(FileProperties expectedFileProperties, FileProperties actualFileProperties, boolean compareChecksums) { FileVersionComparison fileComparison = new FileVersionComparison(); fileComparison.fileChanges = new HashSet<FileChange>(); fileComparison.expectedFileProperties = expectedFileProperties; fileComparison.actualFileProperties = actualFileProperties; boolean cancelFurtherTests = performCancellingTests(fileComparison); if (cancelFurtherTests) { return fileComparison; } switch (actualFileProperties.getType()) { case FILE: compareFile(fileComparison, compareChecksums); break; case FOLDER: compareFolder(fileComparison); break; case SYMLINK: compareSymlink(fileComparison); break; default: throw new RuntimeException("This should not happen. Unknown file type: " + actualFileProperties.getType()); } return fileComparison; } private void compareSymlink(FileVersionComparison fileComparison) { // comparePath(fileComparison); compareSymlinkTarget(fileComparison); } private void compareFolder(FileVersionComparison fileComparison) { // comparePath(fileComparison); compareAttributes(fileComparison); } private void compareFile(FileVersionComparison fileComparison, boolean compareChecksums) { comparePath(fileComparison); compareModifiedDate(fileComparison); compareSize(fileComparison); compareAttributes(fileComparison); // Check if checksum comparison necessary if (fileComparison.getFileChanges().contains(FileChange.CHANGED_SIZE)) { fileComparison.fileChanges.add(FileChange.CHANGED_CHECKSUM); } else if (compareChecksums) { compareChecksum(fileComparison); } } private void compareChecksum(FileVersionComparison fileComparison) { boolean isChecksumEqual = FileChecksum.fileChecksumEquals(fileComparison.expectedFileProperties.getChecksum(), fileComparison.actualFileProperties.getChecksum()); if (!isChecksumEqual) { fileComparison.fileChanges.add(FileChange.CHANGED_CHECKSUM); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected CHECKSUM = {0}, but actual CHECKSUM = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.checksum, fileComparison.actualFileProperties.checksum, fileComparison.actualFileProperties.getRelativePath() }); } } private void compareSymlinkTarget(FileVersionComparison fileComparison) { boolean linkTargetsIdentical = fileComparison.expectedFileProperties.getLinkTarget() != null && fileComparison.expectedFileProperties.getLinkTarget().equals(fileComparison.actualFileProperties.getLinkTarget()); if (!linkTargetsIdentical) { fileComparison.fileChanges.add(FileChange.CHANGED_LINK_TARGET); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected LINK TARGET = {0}, but actual LINK TARGET = {1}, for file {2}", new Object[] { fileComparison.actualFileProperties.getLinkTarget(), fileComparison.expectedFileProperties.getLinkTarget(), fileComparison.actualFileProperties.getRelativePath() }); } } private void compareAttributes(FileVersionComparison fileComparison) { if (EnvironmentUtil.isWindows()) { compareDosAttributes(fileComparison); } else if (EnvironmentUtil.isUnixLikeOperatingSystem()) { comparePosixPermissions(fileComparison); } } private void comparePosixPermissions(FileVersionComparison fileComparison) { boolean posixPermsDiffer = false; boolean actualIsNull = fileComparison.actualFileProperties == null || fileComparison.actualFileProperties.getPosixPermissions() == null; boolean expectedIsNull = fileComparison.expectedFileProperties == null || fileComparison.expectedFileProperties.getPosixPermissions() == null; if (!actualIsNull && !expectedIsNull) { if (!fileComparison.actualFileProperties.getPosixPermissions().equals(fileComparison.expectedFileProperties.getPosixPermissions())) { posixPermsDiffer = true; } } else if ((actualIsNull && !expectedIsNull) || (!actualIsNull && expectedIsNull)) { posixPermsDiffer = true; } if (posixPermsDiffer) { fileComparison.fileChanges.add(FileChange.CHANGED_ATTRIBUTES); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected POSIX ATTRS = {0}, but actual POSIX ATTRS = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.getPosixPermissions(), fileComparison.actualFileProperties.getPosixPermissions(), fileComparison.actualFileProperties.getRelativePath() }); } } private void compareDosAttributes(FileVersionComparison fileComparison) { boolean dosAttrsDiffer = false; boolean actualIsNull = fileComparison.actualFileProperties == null || fileComparison.actualFileProperties.getDosAttributes() == null; boolean expectedIsNull = fileComparison.expectedFileProperties == null || fileComparison.expectedFileProperties.getDosAttributes() == null; if (!actualIsNull && !expectedIsNull) { if (!fileComparison.actualFileProperties.getDosAttributes().equals(fileComparison.expectedFileProperties.getDosAttributes())) { dosAttrsDiffer = true; } } else if ((actualIsNull && !expectedIsNull) || (!actualIsNull && expectedIsNull)) { dosAttrsDiffer = true; } if (dosAttrsDiffer) { fileComparison.fileChanges.add(FileChange.CHANGED_ATTRIBUTES); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected DOS ATTRS = {0}, but actual DOS ATTRS = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.getDosAttributes(), fileComparison.actualFileProperties.getDosAttributes(), fileComparison.actualFileProperties.getRelativePath() }); } } private void compareSize(FileVersionComparison fileComparison) { if (fileComparison.expectedFileProperties.getSize() != fileComparison.actualFileProperties.getSize()) { fileComparison.fileChanges.add(FileChange.CHANGED_SIZE); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected SIZE = {0}, but actual SIZE = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.getSize(), fileComparison.actualFileProperties.getSize(), fileComparison.actualFileProperties.getRelativePath() }); } } private void compareModifiedDate(FileVersionComparison fileComparison) { long timeDifferenceMillis = Math.abs(fileComparison.expectedFileProperties.getLastModified() - fileComparison.actualFileProperties.getLastModified()); // Fuzziness on last modified dates is necessary, see issue #166 if (timeDifferenceMillis > 1000) { fileComparison.fileChanges.add(FileChange.CHANGED_LAST_MOD_DATE); logger.log( Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected MOD. DATE = {0} ({1}), but actual MOD. DATE = {2} ({3}), for file {4}", new Object[] { new Date(fileComparison.expectedFileProperties.getLastModified()), fileComparison.expectedFileProperties.getLastModified(), new Date(fileComparison.actualFileProperties.getLastModified()), fileComparison.actualFileProperties.getLastModified(), fileComparison.actualFileProperties.getRelativePath() }); } } private void comparePath(FileVersionComparison fileComparison) { if (!fileComparison.expectedFileProperties.getRelativePath().equals(fileComparison.actualFileProperties.getRelativePath())) { fileComparison.fileChanges.add(FileChange.CHANGED_PATH); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected PATH = {0}, but actual PATH = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.getRelativePath(), fileComparison.actualFileProperties.getRelativePath(), fileComparison.actualFileProperties.getRelativePath() }); } } private boolean performCancellingTests(FileVersionComparison fileComparison) { // Check null if (fileComparison.actualFileProperties == null && fileComparison.expectedFileProperties == null) { throw new RuntimeException("actualFileProperties and expectedFileProperties cannot be null."); } else if (fileComparison.actualFileProperties != null && fileComparison.expectedFileProperties == null) { throw new RuntimeException("expectedFileProperties cannot be null."); } else if (fileComparison.actualFileProperties == null && fileComparison.expectedFileProperties != null) { if (!fileComparison.expectedFileProperties.exists()) { logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file does not exist, and expected file was deleted, for file {0}", new Object[] { fileComparison.expectedFileProperties.getRelativePath() }); return true; } else { fileComparison.fileChanges.add(FileChange.DELETED); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, actual file is NULL, for file {0}", new Object[] { fileComparison.expectedFileProperties.getRelativePath() }); return true; } } // Check existence if (fileComparison.expectedFileProperties.exists() != fileComparison.actualFileProperties.exists()) { // File is expected to exist, but it does NOT --> file has been deleted if (fileComparison.expectedFileProperties.exists() && !fileComparison.actualFileProperties.exists()) { fileComparison.fileChanges.add(FileChange.DELETED); } // File is expected to NOT exist, but it does --> file is new else { fileComparison.fileChanges.add(FileChange.NEW); } logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected EXISTS = {0}, but actual EXISTS = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.exists(), fileComparison.actualFileProperties.exists(), fileComparison.actualFileProperties.getRelativePath() }); return true; } else if (!fileComparison.expectedFileProperties.exists() && !fileComparison.actualFileProperties.exists()) { logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file does not exist, and expected file was deleted, for file {0}", new Object[] { fileComparison.expectedFileProperties.getRelativePath() }); return true; } // Check file type (folder/file) if (!fileComparison.expectedFileProperties.getType().equals(fileComparison.actualFileProperties.getType())) { fileComparison.fileChanges.add(FileChange.DELETED); logger.log(Level.INFO, " - " + fileComparison.fileChanges + ": Local file DIFFERS from file version, expected TYPE = {0}, but actual TYPE = {1}, for file {2}", new Object[] { fileComparison.expectedFileProperties.getType(), fileComparison.actualFileProperties.getType(), fileComparison.actualFileProperties.getRelativePath() }); return true; } return false; } public FileProperties captureFileProperties(File file, FileChecksum knownChecksum, boolean forceChecksum) { FileProperties fileProperties = new FileProperties(); fileProperties.relativePath = FileUtil.getRelativeDatabasePath(rootFolder, file); Path filePath = null; try { filePath = Paths.get(file.getAbsolutePath()); fileProperties.exists = Files.exists(filePath, LinkOption.NOFOLLOW_LINKS); } catch (InvalidPathException e) { // This throws an exception if the filename is invalid, // e.g. colon in filename on windows "file:name" logger.log(Level.FINE, "InvalidPath", e); logger.log(Level.WARNING, "- Path '{0}' is invalid on this file system. It cannot exist. ", file.getAbsolutePath()); fileProperties.exists = false; return fileProperties; } if (!fileProperties.exists) { return fileProperties; } try { // Read operating system dependent file attributes BasicFileAttributes fileAttributes = null; if (EnvironmentUtil.isWindows()) { DosFileAttributes dosAttrs = Files.readAttributes(filePath, DosFileAttributes.class, LinkOption.NOFOLLOW_LINKS); fileProperties.dosAttributes = FileUtil.dosAttrsToString(dosAttrs); fileAttributes = dosAttrs; } else if (EnvironmentUtil.isUnixLikeOperatingSystem()) { PosixFileAttributes posixAttrs = Files.readAttributes(filePath, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); fileProperties.posixPermissions = PosixFilePermissions.toString(posixAttrs.permissions()); fileAttributes = posixAttrs; } else { fileAttributes = Files.readAttributes(filePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); } fileProperties.lastModified = fileAttributes.lastModifiedTime().toMillis(); fileProperties.size = fileAttributes.size(); // Type if (fileAttributes.isSymbolicLink()) { fileProperties.type = FileType.SYMLINK; fileProperties.linkTarget = FileUtil.readSymlinkTarget(file); } else if (fileAttributes.isDirectory()) { fileProperties.type = FileType.FOLDER; fileProperties.linkTarget = null; } else { fileProperties.type = FileType.FILE; fileProperties.linkTarget = null; } // Checksum if (knownChecksum != null) { fileProperties.checksum = knownChecksum; } else { if (fileProperties.type == FileType.FILE && forceChecksum) { try { if (fileProperties.size > 0) { fileProperties.checksum = new FileChecksum(FileUtil.createChecksum(file, checksumAlgorithm)); } else { fileProperties.checksum = null; } } catch (NoSuchAlgorithmException | IOException e) { logger.log(Level.FINE, "Failed create checksum", e); logger.log(Level.SEVERE, "SEVERE: Unable to create checksum for file {0}", file); fileProperties.checksum = null; } } else { fileProperties.checksum = null; } } // Must be last (!), used for vanish-test later fileProperties.exists = Files.exists(filePath, LinkOption.NOFOLLOW_LINKS); fileProperties.locked = fileProperties.exists && FileUtil.isFileLocked(file); return fileProperties; } catch (IOException e) { logger.log(Level.FINE, "Failed to read file", e); logger.log(Level.SEVERE, "SEVERE: Cannot read file {0}. Assuming file is locked.", file); fileProperties.exists = true; fileProperties.locked = true; return fileProperties; } } public FileProperties captureFileProperties(FileVersion fileVersion) { if (fileVersion == null) { return null; } FileProperties fileProperties = new FileProperties(); fileProperties.lastModified = fileVersion.getLastModified().getTime(); fileProperties.size = fileVersion.getSize(); fileProperties.relativePath = fileVersion.getPath(); fileProperties.linkTarget = fileVersion.getLinkTarget(); fileProperties.checksum = fileVersion.getChecksum(); fileProperties.type = fileVersion.getType(); fileProperties.posixPermissions = fileVersion.getPosixPermissions(); fileProperties.dosAttributes = fileVersion.getDosAttributes(); fileProperties.exists = fileVersion.getStatus() != FileStatus.DELETED; fileProperties.locked = false; return fileProperties; } public static class FileVersionComparison { private Set<FileChange> fileChanges = new HashSet<FileChange>(); private FileProperties actualFileProperties; private FileProperties expectedFileProperties; public boolean areEqual() { return fileChanges.size() == 0; } public Set<FileChange> getFileChanges() { return fileChanges; } public FileProperties getActualFileProperties() { return actualFileProperties; } public FileProperties getExpectedFileProperties() { return expectedFileProperties; } } public static enum FileChange { NEW, CHANGED_CHECKSUM, CHANGED_ATTRIBUTES, CHANGED_LAST_MOD_DATE, CHANGED_LINK_TARGET, CHANGED_SIZE, CHANGED_PATH, DELETED, } public static class FileProperties { private long lastModified = -1; private FileType type = null; private long size = -1; private String relativePath; private String linkTarget; private FileChecksum checksum = null; private boolean locked = true; private boolean exists = false; private String posixPermissions = null; private String dosAttributes = null; public long getLastModified() { return lastModified; } public FileType getType() { return type; } public long getSize() { return size; } public String getRelativePath() { return relativePath; } public String getLinkTarget() { return linkTarget; } public FileChecksum getChecksum() { return checksum; } public boolean isLocked() { return locked; } public boolean exists() { return exists; } public String getPosixPermissions() { return posixPermissions; } public String getDosAttributes() { return dosAttributes; } } }