From 9337642eaa4179b9a18b8fe92f8422365c078b42 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Fri, 6 Dec 2024 21:54:17 +0100 Subject: [PATCH 1/5] first impl draft --- .../cryptofs/CryptoFileSystem.java | 46 +++++++++++++++++++ .../cryptofs/CryptoFileSystemImpl.java | 45 +++++++++++++++++- .../cryptofs/CryptoFileSystemImplTest.java | 2 +- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java index 7f82e2d6..f6ba32f4 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java @@ -1,9 +1,13 @@ package org.cryptomator.cryptofs; +import org.cryptomator.cryptofs.common.Constants; + import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.util.Optional; /** * A {@link FileSystem} which allows access to encrypted data in a directory. @@ -41,6 +45,48 @@ public abstract class CryptoFileSystem extends FileSystem { */ public abstract Path getCiphertextPath(Path cleartextPath) throws IOException; + /** + * Computes from an encrypted node (file or folder) its cleartext name. + *

+ * Due to the structure of a vault, a 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 node of the vault + * @throws UnsupportedOperationException if the directory containing the {@param ciphertextNode} does not have a {@value Constants#DIR_BACKUP_FILE_NAME} file + */ + public String getCleartextName(Path ciphertextNode) throws IOException, IllegalArgumentException, UnsupportedOperationException { + var vaultPath = getPathToVault(); + var absoluteCipherNode = ciphertextNode.toAbsolutePath(); + if (!absoluteCipherNode.startsWith(vaultPath)) { + throw new IllegalArgumentException("The node %s is not a part of vault %s".formatted(absoluteCipherNode, vaultPath)); + } + if (!Files.exists(absoluteCipherNode)) { + throw new NoSuchFileException(absoluteCipherNode.toString()); + } + if (Optional.ofNullable(ciphertextNode.getFileName()) // + .map(Object::toString) // + .filter(s -> s.length() >= Constants.MIN_CIPHER_NAME_LENGTH // + && (s.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX) || s.endsWith(Constants.DEFLATED_FILE_SUFFIX))) // + .isEmpty()) { + throw new IllegalArgumentException("Node %s does not end with %s or %s or is shorter than %d characters.".formatted(ciphertextNode, Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.MIN_CIPHER_NAME_LENGTH)); + } + if (vaultPath.relativize(absoluteCipherNode).getNameCount() != 4) { // d/AB/ABCDDEDED/thisMustBeTheFile.c9r + throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(ciphertextNode)); + } + return getCleartextNameInternal(absoluteCipherNode); + } + + protected abstract String getCleartextNameInternal(Path ciphertextFile) 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 766cdd9c..78ee7b40 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -8,22 +8,28 @@ *******************************************************************************/ package org.cryptomator.cryptofs; +import com.google.common.io.BaseEncoding; import org.cryptomator.cryptofs.attr.AttributeByNameProvider; import org.cryptomator.cryptofs.attr.AttributeProvider; import org.cryptomator.cryptofs.attr.AttributeViewProvider; import org.cryptomator.cryptofs.attr.AttributeViewType; import org.cryptomator.cryptofs.common.ArrayUtils; import org.cryptomator.cryptofs.common.CiphertextFileType; +import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.DeletingFileVisitor; import org.cryptomator.cryptofs.common.FinallyUtil; +import org.cryptomator.cryptofs.common.StringUtils; 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.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; import javax.inject.Inject; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.file.AccessDeniedException; import java.nio.file.AccessMode; @@ -95,6 +101,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final CryptoPath rootPath; private final CryptoPath emptyPath; + private final LongFileNameProvider longFileNameProvider; private volatile boolean open = true; @@ -104,7 +111,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems 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) { + CryptoFileSystemProperties fileSystemProperties, LongFileNameProvider longFileNameProvider) { this.provider = provider; this.cryptoFileSystems = cryptoFileSystems; this.pathToVault = pathToVault; @@ -129,6 +136,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); + this.longFileNameProvider = longFileNameProvider; } @Override @@ -151,6 +159,39 @@ public Path getCiphertextPath(Path cleartextPath) throws IOException { } } + @Override + protected String getCleartextNameInternal(Path ciphertextNode) throws IOException, UnsupportedOperationException { + var dirIdFile = ciphertextNode.resolveSibling(Constants.DIR_BACKUP_FILE_NAME); + var buf = ByteBuffer.allocate(36); + try (var channel = Files.newByteChannel(dirIdFile, StandardOpenOption.READ); // + var decryptingChannel = new DecryptingReadableByteChannel(channel, cryptor, true)) { + int read = decryptingChannel.read(buf.clear()); + if (read < 0 || 36 < read) { //Note: Root_Dir_Id is the empty string! + throw new FileSystemException(dirIdFile.toString(), null, "Invalid directory id: Longer than 36 bytes"); + } + } catch (NoSuchFileException e) { + throw new UnsupportedOperationException("Directory does not have a dirid.c9r file."); + } catch (CryptoException e) { + throw new FileSystemException(dirIdFile.toString(), null, "Decryption of directory id failed:" + e); + } + var dirId = new byte[buf.position()]; + buf.flip().get(dirId); + + 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 cryptor.fileNameCryptor().decryptFilename(BaseEncoding.base64Url(), actualEncryptedName, dirId); + } catch (CryptoException e) { + throw new FileSystemException(ciphertextNode.toString(), null, "Filname decryption failed:" + e); + } + } + @Override public CryptoFileSystemStats getStats() { return stats; diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 43e9e2ce..a5304506 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -127,7 +127,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties); + fileSystemProperties, null); } @Test From f03e42bd1009a272b2e781c1a82d67057824d41a Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sat, 7 Dec 2024 12:38:48 +0100 Subject: [PATCH 2/5] adjust to new methods --- .../cryptofs/CryptoFileSystem.java | 15 ++++++------- .../cryptofs/CryptoFileSystemImpl.java | 21 ++++++------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java index f6ba32f4..790d9fb2 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java @@ -53,24 +53,24 @@ public abstract class CryptoFileSystem extends FileSystem { *
  • the path points into the vault (duh!)
  • *
  • the "file" extension is {@value Constants#CRYPTOMATOR_FILE_SUFFIX} or {@value Constants#DEFLATED_FILE_SUFFIX}
  • *
  • the node name is at least {@value Constants#MIN_CIPHER_NAME_LENGTH} characters long
  • - *
  • it is located at depth 4 from the vault storage root, i.e. d/AB/CDEFG...Z/validFile.c9r
  • + *
  • it is located at depth 4 from the vault storage root, i.e. d/AB/CDEFG...XYZ/validFile.c9r
  • * * * @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 node of the vault - * @throws UnsupportedOperationException if the directory containing the {@param ciphertextNode} does not have a {@value Constants#DIR_BACKUP_FILE_NAME} file + * @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 String getCleartextName(Path ciphertextNode) throws IOException, IllegalArgumentException, UnsupportedOperationException { var vaultPath = getPathToVault(); var absoluteCipherNode = ciphertextNode.toAbsolutePath(); if (!absoluteCipherNode.startsWith(vaultPath)) { - throw new IllegalArgumentException("The node %s is not a part of vault %s".formatted(absoluteCipherNode, vaultPath)); + throw new IllegalArgumentException("Node %s is not a part of vault %s".formatted(absoluteCipherNode, vaultPath)); } - if (!Files.exists(absoluteCipherNode)) { - throw new NoSuchFileException(absoluteCipherNode.toString()); + if (vaultPath.relativize(absoluteCipherNode).getNameCount() != 4) { // d/AB/ABCDDEDED/thisMustBeTheFile.c9r + throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(ciphertextNode)); } if (Optional.ofNullable(ciphertextNode.getFileName()) // .map(Object::toString) // @@ -79,9 +79,6 @@ public String getCleartextName(Path ciphertextNode) throws IOException, IllegalA .isEmpty()) { throw new IllegalArgumentException("Node %s does not end with %s or %s or is shorter than %d characters.".formatted(ciphertextNode, Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.MIN_CIPHER_NAME_LENGTH)); } - if (vaultPath.relativize(absoluteCipherNode).getNameCount() != 4) { // d/AB/ABCDDEDED/thisMustBeTheFile.c9r - throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(ciphertextNode)); - } return getCleartextNameInternal(absoluteCipherNode); } diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index 78ee7b40..6ddf83d5 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -25,11 +25,9 @@ import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; -import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; import javax.inject.Inject; import java.io.IOException; -import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.file.AccessDeniedException; import java.nio.file.AccessMode; @@ -159,24 +157,17 @@ public Path getCiphertextPath(Path cleartextPath) throws IOException { } } + //TODO: test test test @Override protected String getCleartextNameInternal(Path ciphertextNode) throws IOException, UnsupportedOperationException { - var dirIdFile = ciphertextNode.resolveSibling(Constants.DIR_BACKUP_FILE_NAME); - var buf = ByteBuffer.allocate(36); - try (var channel = Files.newByteChannel(dirIdFile, StandardOpenOption.READ); // - var decryptingChannel = new DecryptingReadableByteChannel(channel, cryptor, true)) { - int read = decryptingChannel.read(buf.clear()); - if (read < 0 || 36 < read) { //Note: Root_Dir_Id is the empty string! - throw new FileSystemException(dirIdFile.toString(), null, "Invalid directory id: Longer than 36 bytes"); - } + byte[] dirId = null; + try { + dirId = dirIdBackup.read(ciphertextNode); } catch (NoSuchFileException e) { throw new UnsupportedOperationException("Directory does not have a dirid.c9r file."); - } catch (CryptoException e) { - throw new FileSystemException(dirIdFile.toString(), null, "Decryption of directory id failed:" + e); + } catch (CryptoException | IllegalStateException e) { + throw new FileSystemException(ciphertextNode.toString(), null, "Decryption of dirId backup file failed:" + e); } - var dirId = new byte[buf.position()]; - buf.flip().get(dirId); - var fullCipherNodeName = ciphertextNode.getFileName().toString(); var cipherNodeExtension = fullCipherNodeName.substring(fullCipherNodeName.length() - 4); From ea70bb5caa8652f8d16936994347cfdc675d6005 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 8 Dec 2024 23:13:41 +0100 Subject: [PATCH 3/5] add TestCryptoException --- .../cryptomator/cryptofs/DirectoryIdBackupTest.java | 8 +++----- .../cryptofs/health/dirid/OrphanContentDirTest.java | 8 ++------ .../cryptofs/util/TestCryptoException.java | 11 +++++++++++ 3 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/cryptomator/cryptofs/util/TestCryptoException.java diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java index cd0d1679..fb120e4b 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/health/dirid/OrphanContentDirTest.java b/src/test/java/org/cryptomator/cryptofs/health/dirid/OrphanContentDirTest.java index 48a4954b..934e1f58 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 00000000..7197ef6e --- /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(); + } + +} From 4932e54a614696f00e696488cd9df4c72c7468ab Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Sun, 8 Dec 2024 23:14:38 +0100 Subject: [PATCH 4/5] add unit tests for getCleartextNameInternal --- .../cryptofs/CryptoFileSystemImplTest.java | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index a5304506..69499ac1 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -13,7 +13,9 @@ import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; import org.cryptomator.cryptofs.mocks.FileChannelMock; +import org.cryptomator.cryptofs.util.TestCryptoException; import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.Assertions; @@ -22,6 +24,9 @@ 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.ValueSource; import org.mockito.Mockito; import java.io.IOException; @@ -71,6 +76,7 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -105,6 +111,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 LongFileNameProvider longFileNameProvider = mock(LongFileNameProvider.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -127,7 +134,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties, null); + fileSystemProperties, longFileNameProvider); } @Test @@ -275,6 +282,80 @@ public void testRelativePathException() throws IOException { } } + @Nested + public class GetCiphertextPath { + + @TempDir + Path tmpPath; + + @ParameterizedTest + @DisplayName("Given a ciphertextNode, it's clearname is returned") + @ValueSource(strings = {".c9r", ".c9s"}) + 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 fileNameCryptor = mock(FileNameCryptor.class); + var expectedClearName = "veryClearText"; + when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); + when(longFileNameProvider.inflate(ciphertextNode)).thenReturn(ciphertextNodeNameName); + when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + when(fileNameCryptor.decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId))).thenReturn(expectedClearName); + + var result = inTest.getCleartextNameInternal(ciphertextNode); + verify(fileNameCryptor).decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId)); + Assertions.assertEquals(expectedClearName, result); + } + + @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(UnsupportedOperationException.class); + Assertions.assertThrows(UnsupportedOperationException.class, () -> inTest.getCleartextNameInternal(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, () -> inTest.getCleartextNameInternal(ciphertextNode)); + Assertions.assertThrows(FileSystemException.class, () -> inTest.getCleartextNameInternal(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, () -> inTest.getCleartextNameInternal(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'}; + var fileNameCryptor = mock(FileNameCryptor.class); + when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); + when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + when(fileNameCryptor.decryptFilename(any(), eq(name), eq(dirId))).thenThrow(TestCryptoException.class); + + Assertions.assertThrows(FileSystemException.class, () -> inTest.getCleartextNameInternal(ciphertextNode)); + verify(fileNameCryptor).decryptFilename(any(), eq(name), eq(dirId)); + } + } + @Nested public class CloseAndIsOpen { From 0915cb87967725570a0692800b9b04cd697af006 Mon Sep 17 00:00:00 2001 From: Armin Schrenk Date: Tue, 10 Dec 2024 12:44:28 +0100 Subject: [PATCH 5/5] move feature to its own class --- .../cryptofs/CryptoFileSystem.java | 27 +-- .../cryptofs/CryptoFileSystemImpl.java | 45 ++--- .../cryptofs/FileNameDecryptor.java | 96 ++++++++++ .../cryptofs/CryptoFileSystemImplTest.java | 84 +-------- .../cryptofs/FileNameDecryptorTest.java | 170 ++++++++++++++++++ 5 files changed, 281 insertions(+), 141 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java create mode 100644 src/test/java/org/cryptomator/cryptofs/FileNameDecryptorTest.java diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java index 790d9fb2..de268656 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java @@ -5,9 +5,7 @@ import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.util.Optional; /** * A {@link FileSystem} which allows access to encrypted data in a directory. @@ -46,9 +44,9 @@ public abstract class CryptoFileSystem extends FileSystem { public abstract Path getCiphertextPath(Path cleartextPath) throws IOException; /** - * Computes from an encrypted node (file or folder) its cleartext name. + * Computes from a valid,encrypted node (file or folder) its cleartext name. *

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

      *
    • the path points into the vault (duh!)
    • *
    • the "file" extension is {@value Constants#CRYPTOMATOR_FILE_SUFFIX} or {@value Constants#DEFLATED_FILE_SUFFIX}
    • @@ -63,26 +61,7 @@ public abstract class CryptoFileSystem extends FileSystem { * @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 String getCleartextName(Path ciphertextNode) throws IOException, IllegalArgumentException, UnsupportedOperationException { - var vaultPath = getPathToVault(); - var absoluteCipherNode = ciphertextNode.toAbsolutePath(); - if (!absoluteCipherNode.startsWith(vaultPath)) { - throw new IllegalArgumentException("Node %s is not a part of vault %s".formatted(absoluteCipherNode, vaultPath)); - } - if (vaultPath.relativize(absoluteCipherNode).getNameCount() != 4) { // d/AB/ABCDDEDED/thisMustBeTheFile.c9r - throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(ciphertextNode)); - } - if (Optional.ofNullable(ciphertextNode.getFileName()) // - .map(Object::toString) // - .filter(s -> s.length() >= Constants.MIN_CIPHER_NAME_LENGTH // - && (s.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX) || s.endsWith(Constants.DEFLATED_FILE_SUFFIX))) // - .isEmpty()) { - throw new IllegalArgumentException("Node %s does not end with %s or %s or is shorter than %d characters.".formatted(ciphertextNode, Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.MIN_CIPHER_NAME_LENGTH)); - } - return getCleartextNameInternal(absoluteCipherNode); - } - - protected abstract String getCleartextNameInternal(Path ciphertextFile) throws IOException, UnsupportedOperationException; + 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 6ddf83d5..905e508d 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -8,22 +8,18 @@ *******************************************************************************/ package org.cryptomator.cryptofs; -import com.google.common.io.BaseEncoding; import org.cryptomator.cryptofs.attr.AttributeByNameProvider; import org.cryptomator.cryptofs.attr.AttributeProvider; import org.cryptomator.cryptofs.attr.AttributeViewProvider; import org.cryptomator.cryptofs.attr.AttributeViewType; import org.cryptomator.cryptofs.common.ArrayUtils; import org.cryptomator.cryptofs.common.CiphertextFileType; -import org.cryptomator.cryptofs.common.Constants; import org.cryptomator.cryptofs.common.DeletingFileVisitor; import org.cryptomator.cryptofs.common.FinallyUtil; -import org.cryptomator.cryptofs.common.StringUtils; import org.cryptomator.cryptofs.dir.CiphertextDirectoryDeleter; import org.cryptomator.cryptofs.dir.DirectoryStreamFactory; import org.cryptomator.cryptofs.dir.DirectoryStreamFilters; import org.cryptomator.cryptofs.fh.OpenCryptoFiles; -import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.Cryptor; import javax.inject.Inject; @@ -99,17 +95,17 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final CryptoPath rootPath; private final CryptoPath emptyPath; - private final LongFileNameProvider longFileNameProvider; + 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, LongFileNameProvider longFileNameProvider) { + 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; @@ -134,7 +130,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.rootPath = cryptoPathFactory.rootFor(this); this.emptyPath = cryptoPathFactory.emptyFor(this); - this.longFileNameProvider = longFileNameProvider; + this.fileNameDecryptor = fileNameDecryptor; } @Override @@ -157,30 +153,9 @@ public Path getCiphertextPath(Path cleartextPath) throws IOException { } } - //TODO: test test test @Override - protected String getCleartextNameInternal(Path ciphertextNode) throws IOException, UnsupportedOperationException { - byte[] dirId = null; - try { - dirId = dirIdBackup.read(ciphertextNode); - } catch (NoSuchFileException e) { - throw new UnsupportedOperationException("Directory does not have a dirid.c9r 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 cryptor.fileNameCryptor().decryptFilename(BaseEncoding.base64Url(), actualEncryptedName, dirId); - } catch (CryptoException e) { - throw new FileSystemException(ciphertextNode.toString(), null, "Filname decryption failed:" + e); - } + public String getCleartextName(Path ciphertextNode) throws IOException, UnsupportedOperationException { + return fileNameDecryptor.decryptFilename(ciphertextNode); } @Override 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 00000000..4e155b83 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java @@ -0,0 +1,96 @@ +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 p) { + return vaultPath.relativize(p).getNameCount() == 4; //TODO: relativize is defined for two relative Paths. For two absolute paths, the result depends on the OS + } + + 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 69499ac1..5343dfc7 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -13,9 +13,7 @@ import org.cryptomator.cryptofs.fh.OpenCryptoFiles; import org.cryptomator.cryptofs.fh.OpenCryptoFiles.TwoPhaseMove; import org.cryptomator.cryptofs.mocks.FileChannelMock; -import org.cryptomator.cryptofs.util.TestCryptoException; import org.cryptomator.cryptolib.api.Cryptor; -import org.cryptomator.cryptolib.api.FileNameCryptor; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.Assertions; @@ -24,9 +22,6 @@ 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.ValueSource; import org.mockito.Mockito; import java.io.IOException; @@ -76,7 +71,6 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -111,7 +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 LongFileNameProvider longFileNameProvider = mock(LongFileNameProvider.class); + private final FileNameDecryptor filenameDecryptor = mock(FileNameDecryptor.class); private final CryptoPath root = mock(CryptoPath.class); private final CryptoPath empty = mock(CryptoPath.class); @@ -134,7 +128,7 @@ public void setup() { pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, // fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, // openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, // - fileSystemProperties, longFileNameProvider); + fileSystemProperties, filenameDecryptor); } @Test @@ -282,80 +276,6 @@ public void testRelativePathException() throws IOException { } } - @Nested - public class GetCiphertextPath { - - @TempDir - Path tmpPath; - - @ParameterizedTest - @DisplayName("Given a ciphertextNode, it's clearname is returned") - @ValueSource(strings = {".c9r", ".c9s"}) - 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 fileNameCryptor = mock(FileNameCryptor.class); - var expectedClearName = "veryClearText"; - when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); - when(longFileNameProvider.inflate(ciphertextNode)).thenReturn(ciphertextNodeNameName); - when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); - when(fileNameCryptor.decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId))).thenReturn(expectedClearName); - - var result = inTest.getCleartextNameInternal(ciphertextNode); - verify(fileNameCryptor).decryptFilename(any(), eq(ciphertextNodeNameName), eq(dirId)); - Assertions.assertEquals(expectedClearName, result); - } - - @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(UnsupportedOperationException.class); - Assertions.assertThrows(UnsupportedOperationException.class, () -> inTest.getCleartextNameInternal(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, () -> inTest.getCleartextNameInternal(ciphertextNode)); - Assertions.assertThrows(FileSystemException.class, () -> inTest.getCleartextNameInternal(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, () -> inTest.getCleartextNameInternal(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'}; - var fileNameCryptor = mock(FileNameCryptor.class); - when(dirIdBackup.read(ciphertextNode)).thenReturn(dirId); - when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); - when(fileNameCryptor.decryptFilename(any(), eq(name), eq(dirId))).thenThrow(TestCryptoException.class); - - Assertions.assertThrows(FileSystemException.class, () -> inTest.getCleartextNameInternal(ciphertextNode)); - verify(fileNameCryptor).decryptFilename(any(), eq(name), eq(dirId)); - } - } - @Nested public class CloseAndIsOpen { 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 00000000..2429faf5 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/FileNameDecryptorTest.java @@ -0,0 +1,170 @@ +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.ValueSource; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.FileSystemException; +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(UnsupportedOperationException.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()); + } + } + + +}