transparently show sync conflicts (fixes #98)

This commit is contained in:
Sebastian Stenzel
2016-05-12 16:08:52 +02:00
parent 0d9f8eefc0
commit 9fd6f2ecae
4 changed files with 232 additions and 40 deletions

View File

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

View File

@@ -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);
}
}

View File

@@ -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,27 +78,49 @@ class CryptoFolder extends CryptoNode implements Folder {
}));
}
/* ======================= children ======================= */
@Override
public Stream<? extends Node> children() {
return AutoClosingStream.from(Stream.concat(files(), folders()));
}
private Stream<File> 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<File> 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<CryptoFile> 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<String> isEncryptedFileName() {
return (String name) -> {
final Matcher m = cryptor.getFilenameCryptor().encryptedNamePattern().matcher(name);
return m.matches();
};
@Override
public Stream<CryptoFolder> 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<String> 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<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);
}
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 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();

View File

@@ -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);
}
}