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/crypto/engine/FilenameCryptor.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FilenameCryptor.java
index cb94a12fa..1226da4ed 100644
--- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FilenameCryptor.java
+++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FilenameCryptor.java
@@ -8,6 +8,8 @@
*******************************************************************************/
package org.cryptomator.crypto.engine;
+import java.util.regex.Pattern;
+
/**
* Provides deterministic encryption capabilities as filenames must not change on subsequent encryption attempts,
* otherwise each change results in major directory structure changes which would be a terrible idea for cloud storage encryption.
@@ -22,12 +24,9 @@ public interface FilenameCryptor {
String hashDirectoryId(String cleartextDirectoryId);
/**
- * Tests without an actual decryption attempt, if a name is a well-formed ciphertext.
- *
- * @param ciphertextName Filename in question
- * @return true if the given name is likely to be a valid ciphertext
+ * @return A Pattern that can be used to test, if a name is a well-formed ciphertext.
*/
- boolean isEncryptedFilename(String ciphertextName);
+ Pattern encryptedNamePattern();
/**
* @param cleartextName original filename including cleartext file extension
diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
index 5db611548..4b977d44d 100644
--- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
+++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FilenameCryptorImpl.java
@@ -12,6 +12,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.util.regex.Pattern;
import javax.crypto.AEADBadTagException;
import javax.crypto.SecretKey;
@@ -25,6 +26,7 @@ import org.cryptomator.siv.SivMode;
class FilenameCryptorImpl implements FilenameCryptor {
private static final BaseNCodec BASE32 = new Base32();
+ private static final Pattern BASE32_PATTERN = Pattern.compile("([A-Z0-9]{8})*[A-Z0-9=]{8}");
private static final ThreadLocal SHA1 = new ThreadLocalSha1();
private static final ThreadLocal AES_SIV = new ThreadLocal() {
@Override
@@ -50,8 +52,8 @@ class FilenameCryptorImpl implements FilenameCryptor {
}
@Override
- public boolean isEncryptedFilename(String ciphertextName) {
- return BASE32.isInAlphabet(ciphertextName);
+ public Pattern encryptedNamePattern() {
+ return BASE32_PATTERN;
}
@Override
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..38f321b28
--- /dev/null
+++ b/main/filesystem-crypto/src/main/java/org/cryptomator/filesystem/crypto/ConflictResolver.java
@@ -0,0 +1,81 @@
+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 detected! look for an alternative name:
+ File alternativeFile;
+ String conflictId;
+ do {
+ conflictId = createConflictId();
+ String alternativeCleartext = cleartext + " (Conflict " + conflictId + ")";
+ String alternativeCiphertext = nameEncryptor.apply(alternativeCleartext);
+ alternativeFile = folder.file(isDirectory ? alternativeCiphertext + DIR_SUFFIX : alternativeCiphertext);
+ } while (alternativeFile.exists());
+ 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 c8228535c..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,6 +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.Pattern;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
@@ -36,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()) {
@@ -73,24 +78,49 @@ class CryptoFolder extends CryptoNode implements Folder {
}));
}
+ /* ======================= children ======================= */
+
@Override
public Stream extends Node> children() {
return AutoClosingStream.from(Stream.concat(files(), folders()));
}
+ private Stream nonConflictingFiles() {
+ final Stream extends File> 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 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 nonConflictingFiles().map(File::name).filter(endsWithDirSuffix().negate()).map(this::decryptChildName).map(this::file);
}
- private Predicate isEncryptedFileName() {
- return (String name) -> !name.endsWith(DIR_SUFFIX) && cryptor.getFilenameCryptor().isEncryptedFilename(name);
+ @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
@@ -98,35 +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 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);
- }
-
- private Predicate isEncryptedDirectoryName() {
- return (String name) -> name.endsWith(DIR_SUFFIX) && cryptor.getFilenameCryptor().isEncryptedFilename(StringUtils.removeEnd(name, 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);
- }
-
@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();
@@ -176,7 +192,7 @@ class CryptoFolder extends CryptoNode implements Folder {
// cut all ties:
this.invalidateDirectoryIdsRecursively();
- assert!exists();
+ assert !exists();
assert target.exists();
}
diff --git a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFilenameCryptor.java b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFilenameCryptor.java
index 9f3901f7e..590582ba8 100644
--- a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFilenameCryptor.java
+++ b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFilenameCryptor.java
@@ -12,6 +12,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.util.regex.Pattern;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.BaseNCodec;
@@ -19,6 +20,7 @@ import org.apache.commons.codec.binary.BaseNCodec;
class NoFilenameCryptor implements FilenameCryptor {
private static final BaseNCodec BASE32 = new Base32();
+ private static final Pattern WILDCARD_PATTERN = Pattern.compile(".*");
private static final ThreadLocal SHA1 = new ThreadLocalSha1();
@Override
@@ -29,8 +31,8 @@ class NoFilenameCryptor implements FilenameCryptor {
}
@Override
- public boolean isEncryptedFilename(String ciphertextName) {
- return true;
+ public Pattern encryptedNamePattern() {
+ return WILDCARD_PATTERN;
}
@Override
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);
+ }
+
+}