diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index abe11b30..6848d33f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -22,6 +22,7 @@ requires java.compiler; exports org.cryptomator.cryptofs; + exports org.cryptomator.cryptofs.event; exports org.cryptomator.cryptofs.common; exports org.cryptomator.cryptofs.health.api; exports org.cryptomator.cryptofs.migration; diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java index eacc1972..143f465f 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemModule.java @@ -10,6 +10,7 @@ import org.cryptomator.cryptofs.attr.AttributeComponent; import org.cryptomator.cryptofs.attr.AttributeViewComponent; import org.cryptomator.cryptofs.dir.DirectoryStreamComponent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.fh.OpenCryptoFileComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; +import java.util.function.Consumer; @Module(subcomponents = {AttributeComponent.class, AttributeViewComponent.class, OpenCryptoFileComponent.class, DirectoryStreamComponent.class}) class CryptoFileSystemModule { @@ -35,4 +37,17 @@ public Optional provideNativeFileStore(@PathToVault Path pathToVault) return Optional.empty(); } } + + @Provides + @CryptoFileSystemScoped + public Consumer provideFilesystemEventConsumer(CryptoFileSystemProperties fsProps) { + var eventConsumer = fsProps.filesystemEventConsumer(); + return event -> { + try { + eventConsumer.accept(event); + } catch (RuntimeException e) { + LOG.warn("Filesystem event consumer failed with exception when processing event {}", event, e); + } + }; + } } diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java index fb79a344..30bffeea 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemProperties.java @@ -9,6 +9,7 @@ package org.cryptomator.cryptofs; import com.google.common.base.Strings; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.MasterkeyLoader; @@ -80,6 +81,15 @@ public class CryptoFileSystemProperties extends AbstractMap { static final String DEFAULT_MASTERKEY_FILENAME = "masterkey.cryptomator"; + /** + * Key identifying the function to call for notifications. + * + * @since 2.9.0 + */ + public static final String PROPERTY_EVENT_CONSUMER = "fsEventConsumer"; + + static final Consumer DEFAULT_EVENT_CONSUMER = ignored -> {}; + /** * Key identifying the filesystem flags. * @@ -113,6 +123,7 @@ private CryptoFileSystemProperties(Builder builder) { Map.entry(PROPERTY_FILESYSTEM_FLAGS, builder.flags), // Map.entry(PROPERTY_VAULTCONFIG_FILENAME, builder.vaultConfigFilename), // Map.entry(PROPERTY_MASTERKEY_FILENAME, builder.masterkeyFilename), // + Map.entry(PROPERTY_EVENT_CONSUMER, builder.eventConsumer), // Map.entry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, builder.maxCleartextNameLength), // Map.entry(PROPERTY_SHORTENING_THRESHOLD, builder.shorteningThreshold), // Map.entry(PROPERTY_CIPHER_COMBO, builder.cipherCombo) // @@ -153,6 +164,11 @@ int shorteningThreshold() { return (int) get(PROPERTY_SHORTENING_THRESHOLD); } + @SuppressWarnings("unchecked") + Consumer filesystemEventConsumer() { + return (Consumer) get(PROPERTY_EVENT_CONSUMER); + } + @Override public Set> entrySet() { return entries; @@ -208,6 +224,7 @@ public static class Builder { private String masterkeyFilename = DEFAULT_MASTERKEY_FILENAME; private int maxCleartextNameLength = DEFAULT_MAX_CLEARTEXT_NAME_LENGTH; private int shorteningThreshold = DEFAULT_SHORTENING_THRESHOLD; + private Consumer eventConsumer = DEFAULT_EVENT_CONSUMER; private Builder() { } @@ -220,6 +237,7 @@ private Builder(Map properties) { checkedSet(Integer.class, PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, properties, this::withMaxCleartextNameLength); checkedSet(Integer.class, PROPERTY_SHORTENING_THRESHOLD, properties, this::withShorteningThreshold); checkedSet(CryptorProvider.Scheme.class, PROPERTY_CIPHER_COMBO, properties, this::withCipherCombo); + checkedSet(Consumer.class, PROPERTY_EVENT_CONSUMER, properties, this::withFilesystemEventConsumer); } private void checkedSet(Class type, String key, Map properties, Consumer setter) { @@ -334,6 +352,21 @@ public Builder withMasterkeyFilename(String masterkeyFilename) { return this; } + /** + * Sets the consumer for filesystem events + * + * @param eventConsumer the consumer to receive filesystem events + * @return this + * @since 2.8.0 + */ + public Builder withFilesystemEventConsumer(Consumer eventConsumer) { + if (eventConsumer == null) { + throw new IllegalArgumentException("Parameter eventConsumer must not be null"); + } + this.eventConsumer = eventConsumer; + return this; + } + /** * Validates the values and creates new {@link CryptoFileSystemProperties}. * diff --git a/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java b/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java index 9ca8aacd..42e84ef0 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/C9rConflictResolver.java @@ -6,6 +6,9 @@ import com.google.common.io.RecursiveDeleteOption; import org.cryptomator.cryptofs.VaultConfig; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; +import org.cryptomator.cryptofs.event.ConflictResolvedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.Cryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,6 +21,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.function.Consumer; import java.util.stream.Stream; import static org.cryptomator.cryptofs.common.Constants.DIR_FILE_NAME; @@ -33,14 +37,18 @@ class C9rConflictResolver { private final Cryptor cryptor; private final byte[] dirId; private final int maxC9rFileNameLength; + private final Path cleartextPath; private final int maxCleartextFileNameLength; + private final Consumer eventConsumer; @Inject - public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig) { + public C9rConflictResolver(Cryptor cryptor, @Named("dirId") String dirId, VaultConfig vaultConfig, Consumer eventConsumer, @Named("cleartextPath") Path cleartextPath) { this.cryptor = cryptor; this.dirId = dirId.getBytes(StandardCharsets.US_ASCII); this.maxC9rFileNameLength = vaultConfig.getShorteningThreshold(); + this.cleartextPath = cleartextPath; this.maxCleartextFileNameLength = (maxC9rFileNameLength - 4) / 4 * 3 - 16; // math from FileSystemCapabilityChecker.determineSupportedCleartextFileNameLength() + this.eventConsumer = eventConsumer; } public Stream process(Node node) { @@ -61,13 +69,15 @@ public Stream process(Node node) { Path canonicalPath = node.ciphertextPath.resolveSibling(canonicalCiphertextFileName); return resolveConflict(node, canonicalPath); } catch (IOException e) { + eventConsumer.accept(new ConflictResolutionFailedEvent(cleartextPath.resolve(node.cleartextName), node.ciphertextPath, e)); LOG.error("Failed to resolve conflict for {}", node.ciphertextPath, e); return Stream.empty(); } } } - private Stream resolveConflict(Node conflicting, Path canonicalPath) throws IOException { + //visible for testing + Stream resolveConflict(Node conflicting, Path canonicalPath) throws IOException { Path conflictingPath = conflicting.ciphertextPath; if (resolveConflictTrivially(canonicalPath, conflictingPath)) { Node resolved = new Node(canonicalPath); @@ -132,6 +142,7 @@ private Stream renameConflictingFile(Path canonicalPath, Node conflicting) Node node = new Node(alternativePath); node.cleartextName = alternativeCleartext; node.extractedCiphertext = alternativeCiphertext; + eventConsumer.accept(new ConflictResolvedEvent(cleartextPath.resolve(cleartext), conflicting.ciphertextPath, cleartextPath.resolve(alternativeCleartext), alternativePath)); return Stream.of(node); } diff --git a/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java new file mode 100644 index 00000000..c31dbd6c --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolutionFailedEvent.java @@ -0,0 +1,14 @@ +package org.cryptomator.cryptofs.event; + +import java.nio.file.Path; + +/** + * Emitted, if the conflict resolution inside an encrypted directory failed + * + * @param canonicalCleartextPath path of the canonical file within the cryptographic filesystem + * @param conflictingCiphertextPath path of the encrypted, conflicting file + * @param reason exception, why the resolution failed + */ +public record ConflictResolutionFailedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Exception reason) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java new file mode 100644 index 00000000..b01acbd1 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/ConflictResolvedEvent.java @@ -0,0 +1,20 @@ +package org.cryptomator.cryptofs.event; + +import java.nio.file.Path; + +/** + * Emitted, if a conflict inside an encrypted directory was resolved. + *

+ * A conflict exists, if two encrypted files with the same base64url string exist, but the second file has an arbitrary suffix before the file extension. + * The file without the suffix is called canonical. + * The file with the suffix is called conflicting + * On successful conflict resolution the conflicting file is renamed to the resolved file + * + * @param canonicalCleartextPath path of the canonical file within the cryptographic filesystem + * @param conflictingCiphertextPath path of the encrypted, conflicting file + * @param resolvedCleartextPath path of the resolved file within the cryptographic filesystem + * @param resolvedCiphertextPath path of the resolved, encrypted file + */ +public record ConflictResolvedEvent(Path canonicalCleartextPath, Path conflictingCiphertextPath, Path resolvedCleartextPath, Path resolvedCiphertextPath) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java b/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java new file mode 100644 index 00000000..d7b9eba7 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/DecryptionFailedEvent.java @@ -0,0 +1,15 @@ +package org.cryptomator.cryptofs.event; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; + +import java.nio.file.Path; + +/** + * Emitted, if a decryption operation fails. + * + * @param ciphertextPath path to the encrypted resource + * @param e thrown exception + */ +public record DecryptionFailedEvent(Path ciphertextPath, AuthenticationFailedException e) implements FilesystemEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java new file mode 100644 index 00000000..2a80d3b2 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/event/FilesystemEvent.java @@ -0,0 +1,27 @@ +package org.cryptomator.cryptofs.event; + +import java.util.function.Consumer; + +/** + * Common interface for all filesystem events. + *

+ * Events are emitted via the notification method set in the properties during filesystem creation, see {@link org.cryptomator.cryptofs.CryptoFileSystemProperties.Builder#withFilesystemEventConsumer(Consumer)}. + *

+ * To get a specific event type, use the enhanced switch pattern or typecasting in if-instance of, e.g. + * {@code + * FilesystemEvent fse; + * switch (fse) { + * case DecryptionFailedEvent e -> //do stuff + * case ConflictResolvedEvent e -> //do other stuff + * //other cases + * } + * if( fse instanceof DecryptionFailedEvent dfe) { + * //do more stuff + * } + * }. + * + * @apiNote Events might have occured a long time ago in a galaxy far, far away... therefore, any feedback method is non-blocking and might fail due to changes in the filesystem. + */ +public sealed interface FilesystemEvent permits ConflictResolutionFailedEvent, ConflictResolvedEvent, DecryptionFailedEvent { + +} diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java index e2fafb04..f138d144 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkLoader.java @@ -1,16 +1,24 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import javax.inject.Inject; +import javax.inject.Named; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; @OpenFileScoped class ChunkLoader { + private final Consumer eventConsumer; + private final AtomicReference path; private final Cryptor cryptor; private final ChunkIO ciphertext; private final FileHeaderHolder headerHolder; @@ -18,7 +26,9 @@ class ChunkLoader { private final BufferPool bufferPool; @Inject - public ChunkLoader(Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) { + public ChunkLoader(Consumer eventConsumer, @CurrentOpenFilePath AtomicReference path, Cryptor cryptor, ChunkIO ciphertext, FileHeaderHolder headerHolder, CryptoFileSystemStats stats, BufferPool bufferPool) { + this.eventConsumer = eventConsumer; + this.path = path; this.cryptor = cryptor; this.ciphertext = ciphertext; this.headerHolder = headerHolder; @@ -42,6 +52,9 @@ public ByteBuffer load(Long chunkIndex) throws IOException, AuthenticationFailed stats.addBytesDecrypted(cleartextBuf.remaining()); } return cleartextBuf; + } catch (AuthenticationFailedException e) { + eventConsumer.accept(new DecryptionFailedEvent(path.get(), e)); + throw e; } finally { bufferPool.recycle(ciphertextBuf); } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java index 822b8740..0ca5ad5d 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/FileHeaderHolder.java @@ -1,5 +1,8 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; @@ -13,12 +16,14 @@ import java.nio.file.Path; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; @OpenFileScoped public class FileHeaderHolder { private static final Logger LOG = LoggerFactory.getLogger(FileHeaderHolder.class); + private final Consumer eventConsumer; private final Cryptor cryptor; private final AtomicReference path; private final AtomicReference header = new AtomicReference<>(); @@ -26,7 +31,8 @@ public class FileHeaderHolder { private final AtomicBoolean isPersisted = new AtomicBoolean(); @Inject - public FileHeaderHolder(Cryptor cryptor, @CurrentOpenFilePath AtomicReference path) { + public FileHeaderHolder(Consumer eventConsumer, Cryptor cryptor, @CurrentOpenFilePath AtomicReference path) { + this.eventConsumer = eventConsumer; this.cryptor = cryptor; this.path = path; } @@ -75,6 +81,9 @@ FileHeader loadExisting(FileChannel ch) throws IOException { isPersisted.set(true); return existingHeader; } catch (IllegalArgumentException | CryptoException e) { + if (e instanceof AuthenticationFailedException afe) { + eventConsumer.accept(new DecryptionFailedEvent(path.get(), afe)); + } throw new IOException("Unable to decrypt header of file " + path.get(), e); } } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemModuleTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemModuleTest.java new file mode 100644 index 00000000..4a091074 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemModuleTest.java @@ -0,0 +1,35 @@ +package org.cryptomator.cryptofs; + +import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.nio.file.Path; +import java.util.function.Consumer; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CryptoFileSystemModuleTest { + + CryptoFileSystemModule inTest = new CryptoFileSystemModule(); + + @Test + void testEventConsumerIsDecorated() { + var p = Mockito.mock(Path.class); + var event = new ConflictResolutionFailedEvent(p, p, new RuntimeException()); + var eventConsumer = (Consumer) mock(Consumer.class); + doThrow(new RuntimeException("fail")).when(eventConsumer).accept(event); + var props = mock(CryptoFileSystemProperties.class); + when(props.filesystemEventConsumer()).thenReturn(eventConsumer); + + var decoratedConsumer = inTest.provideFilesystemEventConsumer(props); + Assertions.assertDoesNotThrow(() -> decoratedConsumer.accept(event)); + verify(eventConsumer).accept(event); + } + +} diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java index 34ad89ca..2b689031 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemPropertiesTest.java @@ -51,7 +51,8 @@ public void testSetMasterkeyFilenameAndReadonlyFlag() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); } @Test @@ -77,7 +78,8 @@ public void testFromMap() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, 255), // anEntry(PROPERTY_SHORTENING_THRESHOLD, 221), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); } @Test @@ -99,7 +101,8 @@ public void testWrapMapWithTrueReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.of(FileSystemFlags.READONLY)), // + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); } @Test @@ -121,7 +124,8 @@ public void testWrapMapWithFalseReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)))); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); } @Test @@ -157,6 +161,22 @@ public void testWrapMapWithInvalidPassphrase() { }); } + @Test + public void testWrapMapWithNullEventConsumer() { + Map map = new HashMap<>(); + map.put(PROPERTY_MASTERKEY_FILENAME, "any"); + map.put(PROPERTY_EVENT_CONSUMER, null); + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + CryptoFileSystemProperties.wrap(map); + }); + } + + @Test + public void testNullEventConsumerThrowsIAE() { + Assertions.assertThrows(IllegalArgumentException.class, () -> CryptoFileSystemProperties.cryptoFileSystemProperties().withFilesystemEventConsumer(null)); + } + @Test public void testWrapMapWithoutReadonly() { Map map = new HashMap<>(); @@ -173,9 +193,8 @@ public void testWrapMapWithoutReadonly() { anEntry(PROPERTY_MAX_CLEARTEXT_NAME_LENGTH, DEFAULT_MAX_CLEARTEXT_NAME_LENGTH), // anEntry(PROPERTY_SHORTENING_THRESHOLD, DEFAULT_SHORTENING_THRESHOLD), // anEntry(PROPERTY_CIPHER_COMBO, DEFAULT_CIPHER_COMBO), // - anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)) - ) - ); + anEntry(PROPERTY_FILESYSTEM_FLAGS, EnumSet.noneOf(FileSystemFlags.class)), // + anEntry(PROPERTY_EVENT_CONSUMER, DEFAULT_EVENT_CONSUMER))); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java b/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java index d7a7ba00..0919be34 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/C9rConflictResolverTest.java @@ -1,6 +1,9 @@ package org.cryptomator.cryptofs.dir; import org.cryptomator.cryptofs.VaultConfig; +import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; +import org.cryptomator.cryptofs.event.ConflictResolvedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileNameCryptor; import org.junit.jupiter.api.Assertions; @@ -9,19 +12,26 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.function.Consumer; import java.util.stream.Stream; +import static org.mockito.Mockito.verify; + public class C9rConflictResolverTest { private Cryptor cryptor; private FileNameCryptor fileNameCryptor; private VaultConfig vaultConfig; + private Consumer eventConsumer = Mockito.mock(Consumer.class); + private Path cleartextPath = Mockito.mock(Path.class, "/clear/text/path/"); private C9rConflictResolver conflictResolver; @BeforeEach @@ -31,9 +41,10 @@ public void setup() { vaultConfig = Mockito.mock(VaultConfig.class); Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); Mockito.when(vaultConfig.getShorteningThreshold()).thenReturn(84); // results in max cleartext size = 44 - conflictResolver = new C9rConflictResolver(cryptor, "foo", vaultConfig); + Mockito.when(cleartextPath.resolve(Mockito.anyString())).thenReturn(cleartextPath); + conflictResolver = new C9rConflictResolver(cryptor, "foo", vaultConfig, eventConsumer, cleartextPath); } - + @Test public void testResolveNonConflictingNode() { Node unresolved = new Node(Paths.get("foo.c9r")); @@ -94,6 +105,8 @@ public void testResolveConflictingFileByAddingNumericSuffix(@TempDir Path dir) t Assertions.assertEquals("bar (1).txt", resolved.cleartextName); Assertions.assertTrue(Files.exists(resolved.ciphertextPath)); Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); + var isConflictResolvedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolvedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolvedEvent)); } @Test @@ -113,6 +126,8 @@ public void testResolveConflictingFileByChoosingNewLengthLimitedName(@TempDir Pa Assertions.assertEquals("this is a rather lon (Created by Alice o.txt", resolved.cleartextName); Assertions.assertTrue(Files.exists(resolved.ciphertextPath)); Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); + var isConflictResolvedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolvedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolvedEvent)); } @Test @@ -185,4 +200,24 @@ public void testResolveConflictingSymlinkTrivially(@TempDir Path dir) throws IOE Assertions.assertFalse(Files.exists(unresolved.ciphertextPath)); } + @Test + public void testConflictResolutionFails(@TempDir Path dir) throws IOException { + var p1 = Files.createFile(dir.resolve("foo (1).c9r")); + var p2 = Files.createFile(dir.resolve("foo.c9r")); + Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn("baz"); + Node unresolved = new Node(dir.resolve("foo (1).c9r")); + unresolved.cleartextName = "bar.txt"; + unresolved.extractedCiphertext = "foo"; + + var conflictResolverSpy = Mockito.spy(conflictResolver); + Mockito.doThrow(IOException.class).when(conflictResolverSpy).resolveConflict(Mockito.any(), Mockito.any()); + + Stream result = Assertions.assertDoesNotThrow(() -> conflictResolverSpy.process(unresolved)); + Assertions.assertEquals(0, result.toList().size()); + Assertions.assertTrue(Files.exists(p1)); + Assertions.assertTrue(Files.exists(p2)); + var isConflictResolutionFailedEvent = (ArgumentMatcher) ev -> ev instanceof ConflictResolutionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isConflictResolutionFailedEvent)); + } + } \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java index dac1d303..c54fe415 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/ChunkLoaderTest.java @@ -1,6 +1,8 @@ package org.cryptomator.cryptofs.fh; import org.cryptomator.cryptofs.CryptoFileSystemStats; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptofs.matchers.ByteBufferMatcher; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; @@ -11,11 +13,16 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.mockito.stubbing.Answer; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Supplier; import static org.cryptomator.cryptofs.matchers.ByteBufferMatcher.contains; @@ -25,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,7 +52,9 @@ public class ChunkLoaderTest { private final FileHeader header = mock(FileHeader.class); private final FileHeaderHolder headerHolder = mock(FileHeaderHolder.class); private final BufferPool bufferPool = mock(BufferPool.class); - private final ChunkLoader inTest = new ChunkLoader(cryptor, chunkIO, headerHolder, stats, bufferPool); + private final Consumer eventConsumer = mock(Consumer.class); + private final AtomicReference filePath = new AtomicReference<>(mock(Path.class, "The filepath")); + private final ChunkLoader inTest = new ChunkLoader(eventConsumer, filePath,cryptor, chunkIO, headerHolder, stats, bufferPool); @BeforeEach public void setup() throws IOException { @@ -122,6 +132,23 @@ public void testLoadReturnsDecrytedDataNearEOF() throws IOException, Authenticat Assertions.assertEquals(CLEARTEXT_CHUNK_SIZE, data.capacity()); } + @Test + @DisplayName("load() rethrows on failed decryption the exception and creates an event") + public void testLoadThrowsAndNotifiesOnFailedDecryption() throws IOException { + long chunkIndex = 482L; + long chunkOffset = chunkIndex * CIPHERTEXT_CHUNK_SIZE + HEADER_SIZE; + when(chunkIO.read(argThat(hasAtLeastRemaining(CIPHERTEXT_CHUNK_SIZE)), eq(chunkOffset))).then(fillBufferWith((byte) 3, CIPHERTEXT_CHUNK_SIZE)); + doThrow(new AuthenticationFailedException("FAIL")) // + .when(fileContentCryptor).decryptChunk( // + argThat(contains(repeat(3).times(CIPHERTEXT_CHUNK_SIZE).asByteBuffer())), // + Mockito.any(), eq(chunkIndex), eq(header), eq(true) // + ); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> inTest.load(chunkIndex)); + var isDecryptionFailedEvent = (ArgumentMatcher) ev -> ev instanceof DecryptionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isDecryptionFailedEvent)); + } + private Answer fillBufferWith(byte value, int amount) { return invocation -> { ByteBuffer buffer = invocation.getArgument(0); diff --git a/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java b/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java index ed93fac4..afc8ab31 100644 --- a/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java +++ b/src/test/java/org/cryptomator/cryptofs/fh/FileHeaderHolderTest.java @@ -1,5 +1,7 @@ package org.cryptomator.cryptofs.fh; +import org.cryptomator.cryptofs.event.DecryptionFailedEvent; +import org.cryptomator.cryptofs.event.FilesystemEvent; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; @@ -10,6 +12,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import java.io.IOException; @@ -18,8 +22,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -36,13 +42,15 @@ public class FileHeaderHolderTest { private final Cryptor cryptor = mock(Cryptor.class); private final Path path = mock(Path.class, "openFile.txt"); private final AtomicReference pathRef = new AtomicReference<>(path); + private final Consumer eventConsumer = mock(Consumer.class); - private final FileHeaderHolder inTest = new FileHeaderHolder(cryptor, pathRef); + private FileHeaderHolder inTest; @BeforeEach public void setup() throws IOException { when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); when(fileHeaderCryptor.encryptHeader(Mockito.any())).thenReturn(ByteBuffer.wrap(new byte[0])); + inTest = new FileHeaderHolder(eventConsumer, cryptor, pathRef); } @Nested @@ -66,7 +74,7 @@ public void setup() throws IOException, AuthenticationFailedException { } @Test - @DisplayName("load") + @DisplayName("load success") public void testLoadExisting() throws IOException, AuthenticationFailedException { FileHeader loadedHeader1 = inTest.loadExisting(channel); FileHeader loadedHeader2 = inTest.get(); @@ -81,6 +89,26 @@ public void testLoadExisting() throws IOException, AuthenticationFailedException Assertions.assertTrue(inTest.headerIsPersisted().get()); } + @Test + @DisplayName("load failure due to authenticationFailedException") + public void testLoadExistingFailureWithAuthFailed() { + Mockito.doThrow(AuthenticationFailedException.class).when(fileHeaderCryptor).decryptHeader(Mockito.any()); + + Assertions.assertThrows(IOException.class, () -> inTest.loadExisting(channel)); + var isDecryptionFailedEvent = (ArgumentMatcher) ev -> ev instanceof DecryptionFailedEvent; + verify(eventConsumer).accept(ArgumentMatchers.argThat(isDecryptionFailedEvent)); + } + + @Test + @DisplayName("load failure due to IllegalArgumentException") + public void testLoadExistingFailureWithIllegalArgument() { + Mockito.doThrow(IllegalArgumentException.class).when(fileHeaderCryptor).decryptHeader(Mockito.any()); + + Assertions.assertThrows(IOException.class, () -> inTest.loadExisting(channel)); + var isDecryptionFailedEvent = (ArgumentMatcher) ev -> ev instanceof DecryptionFailedEvent; + verify(eventConsumer, never()).accept(ArgumentMatchers.argThat(isDecryptionFailedEvent)); + } + } @Nested