diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/CryptoException.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/CryptoException.java index 5b9e81ade..f31431003 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/CryptoException.java +++ b/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(); diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/ConflictResolver.java b/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/ConflictResolver.java new file mode 100644 index 000000000..bfcd8660f --- /dev/null +++ b/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 nameDecryptor; + private final UnaryOperator nameEncryptor; + + public ConflictResolver(Pattern encryptedNamePattern, UnaryOperator nameDecryptor, UnaryOperator 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); + } + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/CryptoFolder.java b/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/CryptoFolder.java index 3c2040ea3..39220e6af 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/CryptoFolder.java +++ b/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 folders = WeakValuedCache.usingLoader(this::newFolder); private final WeakValuedCache files = WeakValuedCache.usingLoader(this::newFile); private final AtomicReference 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 encryptedName() { if (parent().isPresent()) { @@ -74,27 +78,49 @@ class CryptoFolder extends CryptoNode implements Folder { })); } + /* ======================= children ======================= */ + @Override public Stream children() { return AutoClosingStream.from(Stream.concat(files(), folders())); } + private Stream nonConflictingFiles() { + final Stream files = physicalFolder().filter(Folder::exists).map(Folder::files).orElse(Stream.empty()); + return files.filter(containsEncryptedName()).map(conflictResolver::resolveIfNecessary); + } + + private Predicate containsEncryptedName() { + final Pattern encryptedNamePattern = cryptor.getFilenameCryptor().encryptedNamePattern(); + return (File file) -> encryptedNamePattern.matcher(file.name()).find(); + } + + private String decryptChildName(String ciphertextFileName) { + final byte[] dirId = getDirectoryId().get().getBytes(UTF_8); + return cryptor.getFilenameCryptor().decryptFilename(ciphertextFileName, dirId); + } + + private String encryptChildName(String cleartextFileName) { + final byte[] dirId = getDirectoryId().get().getBytes(UTF_8); + return cryptor.getFilenameCryptor().encryptFilename(cleartextFileName, dirId); + } + @Override public Stream files() { - final Stream 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 nonConflictingFiles().map(File::name).filter(endsWithDirSuffix().negate()).map(this::decryptChildName).map(this::file); } - private Predicate isEncryptedFileName() { - return (String name) -> { - final Matcher m = cryptor.getFilenameCryptor().encryptedNamePattern().matcher(name); - return m.matches(); - }; + @Override + public Stream folders() { + return nonConflictingFiles().map(File::name).filter(endsWithDirSuffix()).map(this::removeDirSuffix).map(this::decryptChildName).map(this::folder); } - private String decryptChildFileName(String encryptedFileName) { - final byte[] dirId = getDirectoryId().get().getBytes(UTF_8); - return cryptor.getFilenameCryptor().decryptFilename(encryptedFileName, dirId); + private Predicate endsWithDirSuffix() { + return (String encryptedFolderName) -> StringUtils.endsWith(encryptedFolderName, DIR_SUFFIX); + } + + private String removeDirSuffix(String encryptedFolderName) { + return StringUtils.removeEnd(encryptedFolderName, DIR_SUFFIX); } @Override @@ -102,42 +128,21 @@ class CryptoFolder extends CryptoNode implements Folder { return files.get(name); } - public CryptoFile newFile(String name) { - return new CryptoFile(this, name, cryptor); - } - - @Override - public Stream folders() { - final Stream files = physicalFolder().filter(Folder::exists).map(Folder::files).orElse(Stream.empty()); - return files.map(File::name).filter(isEncryptedDirectoryName()).map(this::decryptChildFolderName).map(this::folder); - } - - private Predicate 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 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); - } - @Override public CryptoFolder folder(String name) { 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(); diff --git a/main/filesystem-crypto/src/test/java/org/cryptomator/filesystem/crypto/ConflictResolverTest.java b/main/filesystem-crypto/src/test/java/org/cryptomator/filesystem/crypto/ConflictResolverTest.java new file mode 100644 index 000000000..73536d7d6 --- /dev/null +++ b/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 decode = (s) -> new String(base32.decode(s), StandardCharsets.UTF_8); + UnaryOperator 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); + } + +}