diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java index 7f82e2d62..de268656b 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java @@ -1,5 +1,7 @@ package org.cryptomator.cryptofs; +import org.cryptomator.cryptofs.common.Constants; + import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Files; @@ -41,6 +43,26 @@ public abstract class CryptoFileSystem extends FileSystem { */ public abstract Path getCiphertextPath(Path cleartextPath) throws IOException; + /** + * Computes from a valid,encrypted node (file or folder) its cleartext name. + *

+ * Due to the structure of a vault, an encrypted node is valid if: + *

+ * + * @param ciphertextNode path to the ciphertext file or directory + * @return the cleartext name of the ciphertext file or directory + * @throws java.nio.file.NoSuchFileException if the ciphertextFile does not exist + * @throws IOException if an I/O error occurs reading the ciphertext files + * @throws IllegalArgumentException if {@param ciphertextNode} is not a valid ciphertext content node of the vault + * @throws UnsupportedOperationException if the directory containing the {@param ciphertextNode} does not have a {@value Constants#DIR_ID_BACKUP_FILE_NAME} file + */ + public abstract String getCleartextName(Path ciphertextNode) throws IOException, UnsupportedOperationException; + /** * Provides file system performance statistics. * diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 766cdd9cc..905e508d3 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -17,8 +17,8 @@ import org.cryptomator.cryptofs.common.DeletingFileVisitor; import org.cryptomator.cryptofs.common.FinallyUtil; import org.cryptomator.cryptofs.dir.CiphertextDirectoryDeleter; -import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; import org.cryptomator.cryptofs.dir.DirectoryStreamFactory; +import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptolib.api.Cryptor; @@ -95,16 +95,17 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final CryptoPath rootPath; private final CryptoPath emptyPath; + private final FileNameDecryptor fileNameDecryptor; private volatile boolean open = true; @Inject - public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems cryptoFileSystems, @PathToVault Path pathToVault, Cryptor cryptor, - CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory, - PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup, - AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, - OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, - CryptoFileSystemProperties fileSystemProperties) { + public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems cryptoFileSystems, @PathToVault Path pathToVault, Cryptor cryptor, // + CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory, // + PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup, // + AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, // + OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, // + CryptoFileSystemProperties fileSystemProperties, FileNameDecryptor fileNameDecryptor) { this.provider = provider; this.cryptoFileSystems = cryptoFileSystems; this.pathToVault = pathToVault; @@ -129,6 +130,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); + this.fileNameDecryptor = fileNameDecryptor; } @Override @@ -151,6 +153,11 @@ public Path getCiphertextPath(Path cleartextPath) throws IOException { } } + @Override + public String getCleartextName(Path ciphertextNode) throws IOException, UnsupportedOperationException { + return fileNameDecryptor.decryptFilename(ciphertextNode); + } + @Override public CryptoFileSystemStats getStats() { return stats; diff --git a/src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java b/src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java new file mode 100644 index 000000000..ff312c8af --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java @@ -0,0 +1,99 @@ +package org.cryptomator.cryptofs; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.common.StringUtils; +import org.cryptomator.cryptolib.api.CryptoException; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.FileSystemException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.stream.Stream; + +/** + * @see CryptoFileSystem#getCleartextName(Path) + */ +@CryptoFileSystemScoped +class FileNameDecryptor { + + private final DirectoryIdBackup dirIdBackup; + private final LongFileNameProvider longFileNameProvider; + private final Path vaultPath; + private final FileNameCryptor fileNameCryptor; + + @Inject + public FileNameDecryptor(@PathToVault Path vaultPath, Cryptor cryptor, DirectoryIdBackup dirIdBackup, LongFileNameProvider longFileNameProvider) { + this.vaultPath = vaultPath; + this.fileNameCryptor = cryptor.fileNameCryptor(); + this.dirIdBackup = dirIdBackup; + this.longFileNameProvider = longFileNameProvider; + } + + public String decryptFilename(Path ciphertextNode) throws IOException, UnsupportedOperationException { + validatePath(ciphertextNode.toAbsolutePath()); + return decryptFilenameInternal(ciphertextNode); + } + + @VisibleForTesting + String decryptFilenameInternal(Path ciphertextNode) throws IOException, UnsupportedOperationException { + byte[] dirId = null; + try { + dirId = dirIdBackup.read(ciphertextNode); + } catch (NoSuchFileException e) { + throw new UnsupportedOperationException("Directory does not have a " + Constants.DIR_ID_BACKUP_FILE_NAME + " file."); + } catch (CryptoException | IllegalStateException e) { + throw new FileSystemException(ciphertextNode.toString(), null, "Decryption of dirId backup file failed:" + e); + } + var fullCipherNodeName = ciphertextNode.getFileName().toString(); + var cipherNodeExtension = fullCipherNodeName.substring(fullCipherNodeName.length() - 4); + + String actualEncryptedName = switch (cipherNodeExtension) { + case Constants.CRYPTOMATOR_FILE_SUFFIX -> StringUtils.removeEnd(fullCipherNodeName, Constants.CRYPTOMATOR_FILE_SUFFIX); + case Constants.DEFLATED_FILE_SUFFIX -> longFileNameProvider.inflate(ciphertextNode); + default -> throw new IllegalStateException("SHOULD NOT REACH HERE"); + }; + try { + return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), actualEncryptedName, dirId); + } catch (CryptoException e) { + throw new FileSystemException(ciphertextNode.toString(), null, "Filname decryption failed:" + e); + } + } + + @VisibleForTesting + void validatePath(Path absolutePath) { + if (!belongsToVault(absolutePath)) { + throw new IllegalArgumentException("Node %s is not a part of vault %s".formatted(absolutePath, vaultPath)); + } + if (!isAtCipherNodeLevel(absolutePath)) { + throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(absolutePath)); + } + if (!(hasCipherNodeExtension(absolutePath) && hasMinimumFileNameLength(absolutePath))) { + throw new IllegalArgumentException("Node %s does not end with %s or %s or filename is shorter than %d characters.".formatted(absolutePath, Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.MIN_CIPHER_NAME_LENGTH)); + } + } + + boolean hasCipherNodeExtension(Path p) { + var name = p.getFileName(); + return name != null && Stream.of(Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX).anyMatch(name.toString()::endsWith); + } + + boolean isAtCipherNodeLevel(Path absolutPah) { + if (!absolutPah.isAbsolute()) { + throw new IllegalArgumentException("Path " + absolutPah + "must be absolute"); + } + return absolutPah.subpath(vaultPath.getNameCount(), absolutPah.getNameCount()).getNameCount() == 4; + } + + boolean hasMinimumFileNameLength(Path p) { + return p.getFileName().toString().length() >= Constants.MIN_CIPHER_NAME_LENGTH; + } + + boolean belongsToVault(Path p) { + return p.startsWith(vaultPath); + } +} diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 43e9e2cef..5343dfc7c 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -105,6 +105,7 @@ public class CryptoFileSystemImplTest { private final CiphertextDirectoryDeleter ciphertextDirDeleter = mock(CiphertextDirectoryDeleter.class); private final ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class); private final CryptoFileSystemProperties fileSystemProperties = mock(CryptoFileSystemProperties.class); + private final FileNameDecryptor filenameDecryptor = mock(FileNameDecryptor.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -127,7 +128,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties); + fileSystemProperties, filenameDecryptor); } @Test diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java index cd0d16794..fb120e4b8 100644 --- a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java +++ b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java @@ -1,6 +1,8 @@ package org.cryptomator.cryptofs; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.health.dirid.OrphanContentDirTest; +import org.cryptomator.cryptofs.util.TestCryptoException; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; @@ -103,17 +105,13 @@ public void contentLongerThan36Chars() throws IOException { @DisplayName("If the backup file cannot be decrypted, a CryptoException is thrown") public void invalidEncryptionThrowsCryptoException() throws IOException { var dirIdBackupSpy = spy(dirIdBackup); - var expectedException = new MyCryptoException(); + var expectedException = new TestCryptoException(); Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel); Mockito.when(decChannel.read(Mockito.any())).thenThrow(expectedException); var actual = Assertions.assertThrows(CryptoException.class, () -> dirIdBackupSpy.read(contentPath)); Assertions.assertEquals(expectedException, actual); } - static class MyCryptoException extends CryptoException { - - } - @Test @DisplayName("IOException accessing the file is rethrown") public void ioException() throws IOException { diff --git a/src/test/java/org/cryptomator/cryptofs/FileNameDecryptorTest.java b/src/test/java/org/cryptomator/cryptofs/FileNameDecryptorTest.java new file mode 100644 index 000000000..c5874d8bc --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/FileNameDecryptorTest.java @@ -0,0 +1,209 @@ +package org.cryptomator.cryptofs; + +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.util.TestCryptoException; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.FileSystemException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FileNameDecryptorTest { + + @TempDir + Path tmpPath; + Path vaultPath = mock(Path.class); + DirectoryIdBackup dirIdBackup = mock(DirectoryIdBackup.class); + LongFileNameProvider longFileNameProvider = mock(LongFileNameProvider.class); + FileNameCryptor fileNameCryptor = mock(FileNameCryptor.class); + FileNameDecryptor testObj; + FileNameDecryptor testObjSpy; + + @BeforeEach + public void beforeEach() { + var cryptor = mock(Cryptor.class); + when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + testObj = new FileNameDecryptor(vaultPath, cryptor, dirIdBackup, longFileNameProvider); + testObjSpy = Mockito.spy(testObj); + } + + @ParameterizedTest + @DisplayName("Given a ciphertextNode, it's clearname is returned") + @ValueSource(strings = {Constants.DEFLATED_FILE_SUFFIX, Constants.CRYPTOMATOR_FILE_SUFFIX}) + public void success(String fileExtension) throws IOException { + var ciphertextNodeNameName = "someFile"; + var ciphertextNode = tmpPath.resolve(ciphertextNodeNameName + fileExtension); + var dirId = new byte[]{'f', 'o', 'o', 'b', 'a', 'r'}; + var expectedClearName = "veryClearText"; + when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); + when(longFileNameProvider.inflate(ciphertextNode)).thenReturn(ciphertextNodeNameName); + when(fileNameCryptor.decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId))).thenReturn(expectedClearName); + + var result = testObjSpy.decryptFilenameInternal(ciphertextNode); + verify(fileNameCryptor).decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId)); + Assertions.assertEquals(expectedClearName, result); + } + + @Test + @DisplayName("Path is validated before computation") + public void validatePath() throws IOException { + var ciphertextNode = tmpPath.resolve("someFile.c9r"); + Mockito.doNothing().when(testObjSpy).validatePath(any()); + Mockito.doReturn("veryClearName").when(testObjSpy).decryptFilenameInternal(any()); + + var actual = testObjSpy.decryptFilename(ciphertextNode); + Assertions.assertEquals("veryClearName", actual); + } + + @Test + @DisplayName("If the dirId backup file does not exists, throw UnsupportedOperationException") + public void notExistingDirIdFile() throws IOException { + var ciphertextNode = tmpPath.resolve("toDecrypt.c9r"); + when(dirIdBackup.read(ciphertextNode)).thenThrow(NoSuchFileException.class); + + Assertions.assertThrows(UnsupportedOperationException.class, () -> testObjSpy.decryptFilenameInternal(ciphertextNode)); + } + + @Test + @DisplayName("If the dirId cannot be read, throw FileSystemException") + public void notReadableDirIdFile() throws IOException { + var ciphertextNode = tmpPath.resolve("toDecrypt.c9r"); + when(dirIdBackup.read(ciphertextNode)) // + .thenThrow(TestCryptoException.class) // + .thenThrow(IllegalStateException.class); + Assertions.assertThrows(FileSystemException.class, () -> testObjSpy.decryptFilenameInternal(ciphertextNode)); + Assertions.assertThrows(FileSystemException.class, () -> testObjSpy.decryptFilenameInternal(ciphertextNode)); + } + + @Test + @DisplayName("If the ciphertextName cannot be decrypted, throw FileSystemException") + public void notDecryptableCiphertext() throws IOException { + var name = "toDecrypt"; + var ciphertextNode = tmpPath.resolve(name + ".c9s"); + var dirId = new byte[]{'f', 'o', 'o', 'b', 'a', 'r'}; + var expectedException = new IOException("Inflation failed"); + when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); + when(longFileNameProvider.inflate(ciphertextNode)).thenThrow(expectedException); + + var actual = Assertions.assertThrows(IOException.class, () -> testObjSpy.decryptFilenameInternal(ciphertextNode)); + Assertions.assertEquals(expectedException, actual); + } + + @Test + @DisplayName("If inflating the shortened Name throws exception, it is rethrown") + public void inflateThrows() throws IOException { + var name = "toDecrypt"; + var ciphertextNode = tmpPath.resolve(name + ".c9r"); + var dirId = new byte[]{'f', 'o', 'o', 'b', 'a', 'r'}; + when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); + when(fileNameCryptor.decryptFilename(any(), eq(name), eq(dirId))).thenThrow(TestCryptoException.class); + + Assertions.assertThrows(FileSystemException.class, () -> testObjSpy.decryptFilenameInternal(ciphertextNode)); + verify(fileNameCryptor).decryptFilename(any(), eq(name), eq(dirId)); + } + + @Nested + public class TestValidation { + + Path p = mock(Path.class, "/absolute/path/to/ciphertext.c9r"); + + @BeforeEach + public void beforeEach() { + doReturn(true).when(testObjSpy).belongsToVault(p); + doReturn(true).when(testObjSpy).isAtCipherNodeLevel(p); + doReturn(true).when(testObjSpy).hasCipherNodeExtension(p); + doReturn(true).when(testObjSpy).hasMinimumFileNameLength(p); + } + + @Test + @DisplayName("If node is not part of the vault, validation fails") + public void validateNotVaultFile() { + doReturn(false).when(testObjSpy).belongsToVault(p); + Assertions.assertThrows(IllegalArgumentException.class, () -> testObjSpy.validatePath(p)); + verify(testObjSpy).belongsToVault(any()); + } + + @Test + @DisplayName("If node is on the wrong level, validation fails") + public void validateWrongLevel() { + doReturn(false).when(testObjSpy).isAtCipherNodeLevel(p); + Assertions.assertThrows(IllegalArgumentException.class, () -> testObjSpy.validatePath(p)); + verify(testObjSpy).isAtCipherNodeLevel(any()); + } + + + @Test + @DisplayName("If node has wrong file extension, validation fails") + public void validateWrongExtension() { + doReturn(false).when(testObjSpy).hasCipherNodeExtension(p); + Assertions.assertThrows(IllegalArgumentException.class, () -> testObjSpy.validatePath(p)); + verify(testObjSpy).hasCipherNodeExtension(any()); + } + + @Test + @DisplayName("If filename is too short, validation fails") + public void validateTooShort() { + doReturn(false).when(testObjSpy).hasMinimumFileNameLength(p); + Assertions.assertThrows(IllegalArgumentException.class, () -> testObjSpy.validatePath(p)); + verify(testObjSpy).hasMinimumFileNameLength(any()); + } + } + + @Nested + public class IsAtCipherNodeLevel { + + @TempDir + Path tmpDir; + + @Test + @DisplayName("cipherNodeLevel test requires an absolute path") + public void requiresAbsolutePath() { + var relativePath = Path.of("relative/path"); + Assertions.assertThrows(IllegalArgumentException.class, () -> testObj.isAtCipherNodeLevel(relativePath)); + } + + @Test + public void success() { + when(vaultPath.getNameCount()).thenReturn(tmpDir.getNameCount()); + var p = tmpDir.resolve("d/AA/BBBBBBBBBBBBBBB/encrypted.file"); + Assertions.assertTrue(testObj.isAtCipherNodeLevel(p)); + } + + @Test + public void failure() { + when(vaultPath.getNameCount()).thenReturn(tmpDir.getNameCount()); + var p = tmpDir.resolve("d/AA/other.file"); + Assertions.assertFalse(testObj.isAtCipherNodeLevel(p)); + } + } + + @ParameterizedTest + @DisplayName("Only c9r and c9s are accepted file extensions") + @CsvSource(value = {"file.c9r,true", "file.c9s,true", "filec9r,false", "file.c9l,false",}) + public void testHasCipherNodeExtension(String filename, boolean expected) { + var p = Path.of(filename); + var result = testObj.hasCipherNodeExtension(p); + Assertions.assertEquals(expected, result, "The filename %s is WRONGLY %s".formatted(filename, result ? "accepted" : "rejected")); + } + + +} diff --git a/src/test/java/org/cryptomator/cryptofs/health/dirid/OrphanContentDirTest.java b/src/test/java/org/cryptomator/cryptofs/health/dirid/OrphanContentDirTest.java index 48a4954b0..934e1f582 100644 --- a/src/test/java/org/cryptomator/cryptofs/health/dirid/OrphanContentDirTest.java +++ b/src/test/java/org/cryptomator/cryptofs/health/dirid/OrphanContentDirTest.java @@ -5,8 +5,8 @@ import org.cryptomator.cryptofs.DirectoryIdBackup; import org.cryptomator.cryptofs.VaultConfig; import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptofs.util.TestCryptoException; import org.cryptomator.cryptolib.api.AuthenticationFailedException; -import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileNameCryptor; import org.cryptomator.cryptolib.api.Masterkey; @@ -227,11 +227,7 @@ class RetrieveDirIdTests { private OrphanContentDir resultSpy; - static class MyCryptoException extends CryptoException { - - } - - static List expectedExceptions = List.of(new IOException(), new IllegalStateException(), new MyCryptoException()); + static List expectedExceptions = List.of(new IOException(), new IllegalStateException(), new TestCryptoException()); @BeforeEach public void init() { diff --git a/src/test/java/org/cryptomator/cryptofs/util/TestCryptoException.java b/src/test/java/org/cryptomator/cryptofs/util/TestCryptoException.java new file mode 100644 index 000000000..7197ef6e1 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/util/TestCryptoException.java @@ -0,0 +1,11 @@ +package org.cryptomator.cryptofs.util; + +import org.cryptomator.cryptolib.api.CryptoException; + +public class TestCryptoException extends CryptoException { + + public TestCryptoException() { + super(); + } + +}