Browse Source

transparently show sync conflicts (fixes #98)

Sebastian Stenzel 9 years ago
parent
commit
9fd6f2ecae

+ 1 - 1
main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/CryptoException.java

@@ -8,7 +8,7 @@
  *******************************************************************************/
 package org.cryptomator.crypto.engine;
 
-abstract class CryptoException extends RuntimeException {
+public abstract class CryptoException extends RuntimeException {
 
 	public CryptoException() {
 		super();

+ 77 - 0
main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/ConflictResolver.java

@@ -0,0 +1,77 @@
+package org.cryptomator.filesystem.crypto;
+
+import static org.cryptomator.filesystem.crypto.Constants.DIR_SUFFIX;
+
+import java.util.UUID;
+import java.util.function.UnaryOperator;
+import java.util.regex.MatchResult;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+import org.cryptomator.crypto.engine.CryptoException;
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.Folder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+final class ConflictResolver {
+
+	private static final Logger LOG = LoggerFactory.getLogger(ConflictResolver.class);
+	private static final int UUID_FIRST_GROUP_STRLEN = 8;
+
+	private final Pattern encryptedNamePattern;
+	private final UnaryOperator<String> nameDecryptor;
+	private final UnaryOperator<String> nameEncryptor;
+
+	public ConflictResolver(Pattern encryptedNamePattern, UnaryOperator<String> nameDecryptor, UnaryOperator<String> nameEncryptor) {
+		this.encryptedNamePattern = encryptedNamePattern;
+		this.nameDecryptor = nameDecryptor;
+		this.nameEncryptor = nameEncryptor;
+	}
+
+	public File resolveIfNecessary(File file) {
+		Matcher m = encryptedNamePattern.matcher(StringUtils.removeEnd(file.name(), DIR_SUFFIX));
+		if (m.matches()) {
+			// full match, use file as is
+			return file;
+		} else if (m.find(0)) {
+			// partial match, might be conflicting
+			return resolveConflict(file, m.toMatchResult());
+		} else {
+			// no match, file not relevant
+			return file;
+		}
+	}
+
+	private File resolveConflict(File conflictingFile, MatchResult matchResult) {
+		String ciphertext = matchResult.group();
+		boolean isDirectory = conflictingFile.name().substring(matchResult.end()).startsWith(DIR_SUFFIX);
+		try {
+			String cleartext = nameDecryptor.apply(ciphertext);
+			Folder folder = conflictingFile.parent().get();
+			File canonicalFile = folder.file(isDirectory ? ciphertext + DIR_SUFFIX : ciphertext);
+			if (canonicalFile.exists()) {
+				// CONFLICT!!!!!11
+				String conflictId = createConflictId();
+				String alternativeCleartext = cleartext + " (Conflict " + conflictId + ")";
+				String alternativeCiphertext = nameEncryptor.apply(alternativeCleartext);
+				File alternativeFile = folder.file(isDirectory ? alternativeCiphertext + DIR_SUFFIX : alternativeCiphertext);
+				LOG.info("Detected conflict {}:\n{}\n{}", conflictId, canonicalFile, conflictingFile);
+				conflictingFile.moveTo(alternativeFile);
+				return alternativeFile;
+			} else {
+				conflictingFile.moveTo(canonicalFile);
+				return canonicalFile;
+			}
+		} catch (CryptoException e) {
+			// not decryptable; false positive
+			return conflictingFile;
+		}
+	}
+
+	private String createConflictId() {
+		return UUID.randomUUID().toString().substring(0, UUID_FIRST_GROUP_STRLEN);
+	}
+
+}

+ 37 - 32
main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/CryptoFolder.java

@@ -18,7 +18,7 @@ import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Predicate;
-import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Stream;
 
 import org.apache.commons.lang3.StringUtils;
@@ -37,11 +37,15 @@ class CryptoFolder extends CryptoNode implements Folder {
 	private final WeakValuedCache<String, CryptoFolder> folders = WeakValuedCache.usingLoader(this::newFolder);
 	private final WeakValuedCache<String, CryptoFile> files = WeakValuedCache.usingLoader(this::newFile);
 	private final AtomicReference<String> directoryId = new AtomicReference<>();
+	private final ConflictResolver conflictResolver;
 
 	public CryptoFolder(CryptoFolder parent, String name, Cryptor cryptor) {
 		super(parent, name, cryptor);
+		this.conflictResolver = new ConflictResolver(cryptor.getFilenameCryptor().encryptedNamePattern(), this::decryptChildName, this::encryptChildName);
 	}
 
+	/* ======================= name + directory id ======================= */
+
 	@Override
 	protected Optional<String> encryptedName() {
 		if (parent().isPresent()) {
@@ -74,59 +78,54 @@ class CryptoFolder extends CryptoNode implements Folder {
 		}));
 	}
 
+	/* ======================= children ======================= */
+
 	@Override
 	public Stream<? extends Node> children() {
 		return AutoClosingStream.from(Stream.concat(files(), folders()));
 	}
 
-	@Override
-	public Stream<CryptoFile> files() {
+	private Stream<File> nonConflictingFiles() {
 		final Stream<? extends File> files = physicalFolder().filter(Folder::exists).map(Folder::files).orElse(Stream.empty());
-		return files.map(File::name).filter(isEncryptedFileName()).map(this::decryptChildFileName).map(this::file);
+		return files.filter(containsEncryptedName()).map(conflictResolver::resolveIfNecessary);
 	}
 
-	private Predicate<String> isEncryptedFileName() {
-		return (String name) -> {
-			final Matcher m = cryptor.getFilenameCryptor().encryptedNamePattern().matcher(name);
-			return m.matches();
-		};
+	private Predicate<File> containsEncryptedName() {
+		final Pattern encryptedNamePattern = cryptor.getFilenameCryptor().encryptedNamePattern();
+		return (File file) -> encryptedNamePattern.matcher(file.name()).find();
 	}
 
-	private String decryptChildFileName(String encryptedFileName) {
+	private String decryptChildName(String ciphertextFileName) {
 		final byte[] dirId = getDirectoryId().get().getBytes(UTF_8);
-		return cryptor.getFilenameCryptor().decryptFilename(encryptedFileName, dirId);
+		return cryptor.getFilenameCryptor().decryptFilename(ciphertextFileName, dirId);
 	}
 
-	@Override
-	public CryptoFile file(String name) {
-		return files.get(name);
+	private String encryptChildName(String cleartextFileName) {
+		final byte[] dirId = getDirectoryId().get().getBytes(UTF_8);
+		return cryptor.getFilenameCryptor().encryptFilename(cleartextFileName, dirId);
 	}
 
-	public CryptoFile newFile(String name) {
-		return new CryptoFile(this, name, cryptor);
+	@Override
+	public Stream<CryptoFile> files() {
+		return nonConflictingFiles().map(File::name).filter(endsWithDirSuffix().negate()).map(this::decryptChildName).map(this::file);
 	}
 
 	@Override
 	public Stream<CryptoFolder> folders() {
-		final Stream<? extends File> files = physicalFolder().filter(Folder::exists).map(Folder::files).orElse(Stream.empty());
-		return files.map(File::name).filter(isEncryptedDirectoryName()).map(this::decryptChildFolderName).map(this::folder);
+		return nonConflictingFiles().map(File::name).filter(endsWithDirSuffix()).map(this::removeDirSuffix).map(this::decryptChildName).map(this::folder);
 	}
 
-	private Predicate<String> isEncryptedDirectoryName() {
-		return (String name) -> {
-			if (name.endsWith(DIR_SUFFIX)) {
-				final Matcher m = cryptor.getFilenameCryptor().encryptedNamePattern().matcher(StringUtils.removeEnd(name, DIR_SUFFIX));
-				return m.matches();
-			} else {
-				return false;
-			}
-		};
+	private Predicate<String> endsWithDirSuffix() {
+		return (String encryptedFolderName) -> StringUtils.endsWith(encryptedFolderName, DIR_SUFFIX);
 	}
 
-	private String decryptChildFolderName(String encryptedFolderName) {
-		final byte[] dirId = getDirectoryId().get().getBytes(UTF_8);
-		final String ciphertext = StringUtils.removeEnd(encryptedFolderName, DIR_SUFFIX);
-		return cryptor.getFilenameCryptor().decryptFilename(ciphertext, dirId);
+	private String removeDirSuffix(String encryptedFolderName) {
+		return StringUtils.removeEnd(encryptedFolderName, DIR_SUFFIX);
+	}
+
+	@Override
+	public CryptoFile file(String name) {
+		return files.get(name);
 	}
 
 	@Override
@@ -134,10 +133,16 @@ class CryptoFolder extends CryptoNode implements Folder {
 		return folders.get(name);
 	}
 
-	public CryptoFolder newFolder(String name) {
+	private CryptoFile newFile(String name) {
+		return new CryptoFile(this, name, cryptor);
+	}
+
+	private CryptoFolder newFolder(String name) {
 		return new CryptoFolder(this, name, cryptor);
 	}
 
+	/* ======================= create/move/delete ======================= */
+
 	@Override
 	public void create() {
 		parent.create();

+ 110 - 0
main/filesystem-crypto/src/test/java/org/cryptomator/filesystem/crypto/ConflictResolverTest.java

@@ -0,0 +1,110 @@
+package org.cryptomator.filesystem.crypto;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.function.UnaryOperator;
+import java.util.regex.Pattern;
+
+import org.apache.commons.codec.binary.Base32;
+import org.apache.commons.codec.binary.BaseNCodec;
+import org.cryptomator.filesystem.File;
+import org.cryptomator.filesystem.Folder;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class ConflictResolverTest {
+
+	private ConflictResolver conflictResolver;
+	private Folder folder;
+	private File canonicalFile;
+	private File canonicalFolder;
+	private File conflictingFile;
+	private File conflictingFolder;
+	private File resolved;
+	private File unrelatedFile;
+
+	@Before
+	public void setup() {
+		Pattern base32Pattern = Pattern.compile("([A-Z0-9]{8})*[A-Z0-9=]{8}");
+		BaseNCodec base32 = new Base32();
+		UnaryOperator<String> decode = (s) -> new String(base32.decode(s), StandardCharsets.UTF_8);
+		UnaryOperator<String> encode = (s) -> base32.encodeAsString(s.getBytes(StandardCharsets.UTF_8));
+		conflictResolver = new ConflictResolver(base32Pattern, decode, encode);
+
+		folder = Mockito.mock(Folder.class);
+		canonicalFile = Mockito.mock(File.class);
+		canonicalFolder = Mockito.mock(File.class);
+		conflictingFile = Mockito.mock(File.class);
+		conflictingFolder = Mockito.mock(File.class);
+		resolved = Mockito.mock(File.class);
+		unrelatedFile = Mockito.mock(File.class);
+
+		String canonicalFileName = encode.apply("test name");
+		String canonicalFolderName = encode.apply("test name") + Constants.DIR_SUFFIX;
+		String conflictingFileName = canonicalFileName + " (version 2)";
+		String conflictingFolderName = canonicalFolderName + " (version 2)";
+		String unrelatedName = "notBa$e32!";
+
+		Mockito.when(canonicalFile.name()).thenReturn(canonicalFileName);
+		Mockito.when(canonicalFolder.name()).thenReturn(canonicalFolderName);
+		Mockito.when(conflictingFile.name()).thenReturn(conflictingFileName);
+		Mockito.when(conflictingFolder.name()).thenReturn(conflictingFolderName);
+		Mockito.when(unrelatedFile.name()).thenReturn(unrelatedName);
+
+		Mockito.when(canonicalFile.exists()).thenReturn(true);
+		Mockito.when(canonicalFolder.exists()).thenReturn(true);
+		Mockito.when(conflictingFile.exists()).thenReturn(true);
+		Mockito.when(conflictingFolder.exists()).thenReturn(true);
+		Mockito.when(unrelatedFile.exists()).thenReturn(true);
+
+		Mockito.doReturn(Optional.of(folder)).when(canonicalFile).parent();
+		Mockito.doReturn(Optional.of(folder)).when(canonicalFolder).parent();
+		Mockito.doReturn(Optional.of(folder)).when(conflictingFile).parent();
+		Mockito.doReturn(Optional.of(folder)).when(conflictingFolder).parent();
+		Mockito.doReturn(Optional.of(folder)).when(unrelatedFile).parent();
+
+		Mockito.when(folder.file(Mockito.startsWith(canonicalFileName.substring(0, 8)))).thenReturn(resolved);
+		Mockito.when(folder.file(canonicalFileName)).thenReturn(canonicalFile);
+		Mockito.when(folder.file(canonicalFolderName)).thenReturn(canonicalFolder);
+		Mockito.when(folder.file(conflictingFileName)).thenReturn(conflictingFile);
+		Mockito.when(folder.file(conflictingFolderName)).thenReturn(conflictingFolder);
+		Mockito.when(folder.file(unrelatedName)).thenReturn(unrelatedFile);
+	}
+
+	@Test
+	public void testCanonicalName() {
+		File resolved = conflictResolver.resolveIfNecessary(canonicalFile);
+		Assert.assertSame(canonicalFile, resolved);
+	}
+
+	@Test
+	public void testUnrelatedName() {
+		File resolved = conflictResolver.resolveIfNecessary(unrelatedFile);
+		Assert.assertSame(unrelatedFile, resolved);
+	}
+
+	@Test
+	public void testConflictingFile() {
+		File resolved = conflictResolver.resolveIfNecessary(conflictingFile);
+		Mockito.verify(conflictingFile).moveTo(resolved);
+		Assert.assertSame(resolved, resolved);
+	}
+
+	@Test
+	public void testConflictingFileIfCanonicalDoesnExist() {
+		Mockito.when(canonicalFile.exists()).thenReturn(false);
+		File resolved = conflictResolver.resolveIfNecessary(conflictingFile);
+		Mockito.verify(conflictingFile).moveTo(canonicalFile);
+		Assert.assertSame(canonicalFile, resolved);
+	}
+
+	@Test
+	public void testConflictingFolder() {
+		File resolved = conflictResolver.resolveIfNecessary(conflictingFolder);
+		Mockito.verify(conflictingFolder).moveTo(resolved);
+		Assert.assertSame(resolved, resolved);
+	}
+
+}