mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-05-17 10:11:27 +00:00
transparently show sync conflicts (fixes #98)
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
*******************************************************************************/
|
||||
package org.cryptomator.crypto.engine;
|
||||
|
||||
abstract class CryptoException extends RuntimeException {
|
||||
public abstract class CryptoException extends RuntimeException {
|
||||
|
||||
public CryptoException() {
|
||||
super();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user