diff --git a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java index d1da6a78..d24a622c 100644 --- a/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java +++ b/src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java @@ -68,7 +68,7 @@ @CryptoFileSystemScoped class CryptoFileSystemImpl extends CryptoFileSystem { - + private final CryptoFileSystemProvider provider; private final CryptoFileSystems cryptoFileSystems; private final Path pathToVault; @@ -80,6 +80,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { private final PathMatcherFactory pathMatcherFactory; private final DirectoryStreamFactory directoryStreamFactory; private final DirectoryIdProvider dirIdProvider; + private final DirectoryIdBackup dirIdBackup; private final AttributeProvider fileAttributeProvider; private final AttributeByNameProvider fileAttributeByNameProvider; private final AttributeViewProvider fileAttributeViewProvider; @@ -98,7 +99,7 @@ class CryptoFileSystemImpl extends CryptoFileSystem { @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, + 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) { @@ -113,6 +114,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems this.pathMatcherFactory = pathMatcherFactory; this.directoryStreamFactory = directoryStreamFactory; this.dirIdProvider = dirIdProvider; + this.dirIdBackup = dirIdBackup; this.fileAttributeProvider = fileAttributeProvider; this.fileAttributeByNameProvider = fileAttributeByNameProvider; this.fileAttributeViewProvider = fileAttributeViewProvider; @@ -235,8 +237,8 @@ A readAttributes(CryptoPath cleartextPath, Class /** * @param cleartextPath the path to the file - * @param type the Class object corresponding to the file attribute view - * @param options future use + * @param type the Class object corresponding to the file attribute view + * @param options future use * @return a file attribute view of the specified type, or null if the attribute view type is not available * @see AttributeViewProvider#getAttributeView(CryptoPath, Class, LinkOption...) */ @@ -302,6 +304,7 @@ void createDirectory(CryptoPath cleartextDir, FileAttribute... attrs) throws // create dir if and only if the dirFile has been created right now (not if it has been created before): try { Files.createDirectories(ciphertextDir.path); + dirIdBackup.execute(ciphertextDir); ciphertextPath.persistLongFileName(); } catch (IOException e) { // make sure there is no orphan dir file: @@ -582,7 +585,7 @@ private void moveDirectory(CryptoPath cleartextSource, CryptoPath cleartextTarge } Files.walkFileTree(ciphertextTarget.getRawPath(), DeletingFileVisitor.INSTANCE); } - + // no exceptions until this point, so MOVE: Files.move(ciphertextSource.getRawPath(), ciphertextTarget.getRawPath(), options); if (ciphertextTarget.isShortened()) { diff --git a/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java new file mode 100644 index 00000000..9ebe78bb --- /dev/null +++ b/src/main/java/org/cryptomator/cryptofs/DirectoryIdBackup.java @@ -0,0 +1,46 @@ +package org.cryptomator.cryptofs; + +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; + +/** + * Single purpose class to backup the directory id of an encrypted directory when it is created. + */ +@CryptoFileSystemScoped +public class DirectoryIdBackup { + + private Cryptor cryptor; + + @Inject + public DirectoryIdBackup(Cryptor cryptor) { + this.cryptor = cryptor; + } + + /** + * Performs the backup operation for the given {@link CryptoPathMapper.CiphertextDirectory} object. + *

+ * The directory id is written via an encrypting channel to the file {@link CryptoPathMapper.CiphertextDirectory#path}/{@value Constants#DIR_ID_FILE}. + * + * @param ciphertextDirectory The cipher dir object containing the dir id and the encrypted content root + * @throws IOException if an IOException is raised during the write operation + */ + public void execute(CryptoPathMapper.CiphertextDirectory ciphertextDirectory) throws IOException { + try (var channel = Files.newByteChannel(ciphertextDirectory.path.resolve(Constants.DIR_ID_FILE), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); // + var encryptingChannel = wrapEncryptionAround(channel, cryptor)) { + encryptingChannel.write(ByteBuffer.wrap(ciphertextDirectory.dirId.getBytes(StandardCharsets.UTF_8))); + } + } + + static EncryptingWritableByteChannel wrapEncryptionAround(ByteChannel channel, Cryptor cryptor) { + return new EncryptingWritableByteChannel(channel, cryptor); + } +} diff --git a/src/main/java/org/cryptomator/cryptofs/common/Constants.java b/src/main/java/org/cryptomator/cryptofs/common/Constants.java index dd95f02c..b56dbfee 100644 --- a/src/main/java/org/cryptomator/cryptofs/common/Constants.java +++ b/src/main/java/org/cryptomator/cryptofs/common/Constants.java @@ -29,7 +29,9 @@ private Constants() { public static final int DEFAULT_SHORTENING_THRESHOLD = 220; public static final int MAX_SYMLINK_LENGTH = 32767; // max path length on NTFS and FAT32: 32k-1 public static final int MAX_DIR_FILE_LENGTH = 36; // UUIDv4: hex-encoded 16 byte int + 4 hyphens = 36 ASCII chars + public static final int MIN_CIPHER_NAME_LENGTH = 26; //rounded up base64url encoded (16 bytes IV + 0 bytes empty string) + file suffix = 26 ASCII chars public static final String SEPARATOR = "/"; public static final String RECOVERY_DIR_NAME = "CRYPTOMATOR_RECOVERY"; + public static final String DIR_ID_FILE = "dirid.c9r"; } diff --git a/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java b/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java index d16bbeb8..0b614c88 100644 --- a/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java +++ b/src/main/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactory.java @@ -4,6 +4,7 @@ import org.cryptomator.cryptofs.CryptoPath; import org.cryptomator.cryptofs.CryptoPathMapper; import org.cryptomator.cryptofs.CryptoPathMapper.CiphertextDirectory; +import org.cryptomator.cryptofs.common.Constants; import javax.inject.Inject; import java.io.IOException; @@ -13,7 +14,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; @CryptoFileSystemScoped @@ -21,7 +21,7 @@ public class DirectoryStreamFactory { private final CryptoPathMapper cryptoPathMapper; private final DirectoryStreamComponent.Builder directoryStreamComponentBuilder; // sharing reusable builder via synchronized - private final Map streams = new HashMap<>(); + private final Map> streams = new HashMap<>(); private volatile boolean closed = false; @@ -36,7 +36,8 @@ public synchronized CryptoDirectoryStream newDirectoryStream(CryptoPath cleartex throw new ClosedFileSystemException(); } CiphertextDirectory ciphertextDir = cryptoPathMapper.getCiphertextDir(cleartextDir); - DirectoryStream ciphertextDirStream = Files.newDirectoryStream(ciphertextDir.path); + //TODO: use HealthCheck with warning and suggest fix to create one + DirectoryStream ciphertextDirStream = Files.newDirectoryStream(ciphertextDir.path, this::matchesEncryptedContentPattern); CryptoDirectoryStream cleartextDirStream = directoryStreamComponentBuilder // .dirId(ciphertextDir.dirId) // .ciphertextDirectoryStream(ciphertextDirStream) // @@ -49,12 +50,19 @@ public synchronized CryptoDirectoryStream newDirectoryStream(CryptoPath cleartex return cleartextDirStream; } + //visible for testing + boolean matchesEncryptedContentPattern(Path path) { + var tmp = path.getFileName().toString(); + return tmp.length() >= Constants.MIN_CIPHER_NAME_LENGTH // + && (tmp.endsWith(Constants.CRYPTOMATOR_FILE_SUFFIX) || tmp.endsWith(Constants.DEFLATED_FILE_SUFFIX)); + } + public synchronized void close() throws IOException { closed = true; IOException exception = new IOException("Close failed"); - Iterator> iter = streams.entrySet().iterator(); + var iter = streams.entrySet().iterator(); while (iter.hasNext()) { - Map.Entry entry = iter.next(); + var entry = iter.next(); iter.remove(); try { entry.getKey().close(); diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index 3698bce0..4af8ce2f 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -88,6 +88,7 @@ public class CryptoFileSystemImplTest { private final Symlinks symlinks = mock(Symlinks.class); private final CryptoPathMapper cryptoPathMapper = mock(CryptoPathMapper.class); private final DirectoryIdProvider dirIdProvider = mock(DirectoryIdProvider.class); + private final DirectoryIdBackup dirIdBackup = mock(DirectoryIdBackup.class); private final AttributeProvider fileAttributeProvider = mock(AttributeProvider.class); private final AttributeByNameProvider fileAttributeByNameProvider = mock(AttributeByNameProvider.class); private final AttributeViewProvider fileAttributeViewProvider = mock(AttributeViewProvider.class); @@ -118,7 +119,7 @@ public void setup() { inTest = new CryptoFileSystemImpl(provider, cryptoFileSystems, pathToVault, cryptor, fileStore, stats, cryptoPathMapper, cryptoPathFactory, - pathMatcherFactory, directoryStreamFactory, dirIdProvider, + pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, fileSystemProperties); @@ -1103,6 +1104,37 @@ public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() thro verify(cryptoPathMapper).invalidatePathMapping(path); } + @Test + public void createDirectoryBackupsDirIdInCiphertextDirPath() throws IOException { + Path ciphertextParent = mock(Path.class, "ciphertextParent"); + Path ciphertextRawPath = mock(Path.class, "d/00/00/path.c9r"); + Path ciphertextDirFile = mock(Path.class, "d/00/00/path.c9r/dir.c9r"); + Path ciphertextDirPath = mock(Path.class, "d/FF/FF/"); + CiphertextFilePath ciphertextPath = mock(CiphertextFilePath.class, "ciphertext"); + String dirId = "DirId1234ABC"; + CiphertextDirectory cipherDirObject = new CiphertextDirectory(dirId, ciphertextDirPath); + FileChannelMock channel = new FileChannelMock(100); + when(ciphertextRawPath.resolve("dir.c9r")).thenReturn(ciphertextDirFile); + when(cryptoPathMapper.getCiphertextFilePath(path)).thenReturn(ciphertextPath); + when(cryptoPathMapper.getCiphertextDir(path)).thenReturn(cipherDirObject); + when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextDirPath)); + when(cryptoPathMapper.getCiphertextFileType(path)).thenThrow(NoSuchFileException.class); + when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath); + when(ciphertextPath.getDirFilePath()).thenReturn(ciphertextDirFile); + when(ciphertextParent.getFileSystem()).thenReturn(fileSystem); + when(ciphertextRawPath.getFileSystem()).thenReturn(fileSystem); + when(ciphertextDirFile.getFileSystem()).thenReturn(fileSystem); + when(ciphertextDirPath.getFileSystem()).thenReturn(fileSystem); + when(ciphertextDirFile.getName(3)).thenReturn(mock(Path.class, "path.c9r")); + when(provider.newFileChannel(ciphertextDirFile, EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))).thenReturn(channel); + + inTest.createDirectory(path); + + verify(readonlyFlag).assertWritable(); + verify(dirIdBackup, Mockito.times(1)).execute(cipherDirObject); + } + + } @Nested diff --git a/src/test/java/org/cryptomator/cryptofs/DeleteNonEmptyCiphertextDirectoryIntegrationTest.java b/src/test/java/org/cryptomator/cryptofs/DeleteNonEmptyCiphertextDirectoryIntegrationTest.java index a52f9c15..3c04a6fa 100644 --- a/src/test/java/org/cryptomator/cryptofs/DeleteNonEmptyCiphertextDirectoryIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptofs/DeleteNonEmptyCiphertextDirectoryIntegrationTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mockito; @@ -28,6 +29,7 @@ import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.util.function.Predicate; import java.util.stream.Stream; import static java.nio.file.StandardOpenOption.CREATE_NEW; @@ -159,21 +161,35 @@ private Path firstEmptyCiphertextDirectory() throws IOException { try (Stream allFilesInVaultDir = Files.walk(pathToVault)) { return allFilesInVaultDir // .filter(Files::isDirectory) // - .filter(this::isEmptyDirectory) // + .filter(this::isEmptyCryptoFsDirectory) // .filter(this::isEncryptedDirectory) // .findFirst() // .get(); } } - private boolean isEmptyDirectory(Path path) { + private boolean isEmptyCryptoFsDirectory(Path path) { + Predicate isIgnoredFile = p -> Constants.DIR_ID_FILE.equals(p.getFileName().toString()); try (Stream files = Files.list(path)) { - return files.count() == 0; + return files.noneMatch(isIgnoredFile.negate()); } catch (IOException e) { throw new UncheckedIOException(e); } } + @Test + @DisplayName("Tests internal cryptofs directory emptiness definition") + public void testCryptoFsDirEmptiness() throws IOException { + var emptiness = pathToVault.getParent().resolve("emptiness"); + var ignoredFile = emptiness.resolve(Constants.DIR_ID_FILE); + Files.createDirectory(emptiness); + Files.createFile(ignoredFile); + + boolean result = isEmptyCryptoFsDirectory(emptiness); + + Assertions.assertTrue(result, "Ciphertext directory containing only dirId-file should be accepted as an empty dir"); + } + private boolean isEncryptedDirectory(Path pathInVault) { Path relativePath = pathToVault.relativize(pathInVault); String relativePathAsString = relativePath.toString().replace(File.separatorChar, '/'); diff --git a/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java new file mode 100644 index 00000000..cf5441c2 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptofs/DirectoryIdBackupTest.java @@ -0,0 +1,67 @@ +package org.cryptomator.cryptofs; + +import org.cryptomator.cryptofs.common.Constants; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class DirectoryIdBackupTest { + + @TempDir + Path contentPath; + + private String dirId = "12345678"; + private CryptoPathMapper.CiphertextDirectory cipherDirObject; + private EncryptingWritableByteChannel encChannel; + private Cryptor cryptor; + + private DirectoryIdBackup dirIdBackup; + + + @BeforeEach + public void init() { + cipherDirObject = new CryptoPathMapper.CiphertextDirectory(dirId, contentPath); + cryptor = Mockito.mock(Cryptor.class); + encChannel = Mockito.mock(EncryptingWritableByteChannel.class); + + dirIdBackup = new DirectoryIdBackup(cryptor); + } + + @Test + public void testIdFileCreated() throws IOException { + try (MockedStatic backupMock = Mockito.mockStatic(DirectoryIdBackup.class)) { + backupMock.when(() -> DirectoryIdBackup.wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(encChannel); + Mockito.when(encChannel.write(Mockito.any())).thenReturn(0); + + dirIdBackup.execute(cipherDirObject); + + Assertions.assertTrue(Files.exists(contentPath.resolve(Constants.DIR_ID_FILE))); + } + } + + @Test + public void testContentIsWritten() throws IOException { + Mockito.when(encChannel.write(Mockito.any())).thenReturn(0); + var expectedWrittenContent = ByteBuffer.wrap(dirId.getBytes(StandardCharsets.UTF_8)); + + try (MockedStatic backupMock = Mockito.mockStatic(DirectoryIdBackup.class)) { + backupMock.when(() -> DirectoryIdBackup.wrapEncryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(encChannel); + + dirIdBackup.execute(cipherDirObject); + + Mockito.verify(encChannel, Mockito.times(1)).write(Mockito.argThat(b -> b.equals(expectedWrittenContent))); + } + } + +} diff --git a/src/test/java/org/cryptomator/cryptofs/dir/CryptoDirectoryStreamTest.java b/src/test/java/org/cryptomator/cryptofs/dir/CryptoDirectoryStreamTest.java index c7c2f0d6..30474546 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/CryptoDirectoryStreamTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/CryptoDirectoryStreamTest.java @@ -11,7 +11,6 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatcher; import org.mockito.Mockito; import java.io.IOException; @@ -32,7 +31,7 @@ public class CryptoDirectoryStreamTest { private static final Consumer DO_NOTHING_ON_CLOSE = ignored -> { }; private static final Filter ACCEPT_ALL = ignored -> true; - + private NodeProcessor nodeProcessor; private DirectoryStream dirStream; @@ -70,7 +69,7 @@ public void testDirListing() throws IOException { Mockito.doAnswer(invocation -> { return Stream.empty(); }).when(nodeProcessor).process(Mockito.argThat(node -> node.fullCiphertextFileName.equals("invalidCiphertext"))); - + try (CryptoDirectoryStream stream = new CryptoDirectoryStream("foo", dirStream, cleartextPath, ACCEPT_ALL, DO_NOTHING_ON_CLOSE, nodeProcessor)) { Iterator iter = stream.iterator(); Assertions.assertTrue(iter.hasNext()); diff --git a/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java b/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java index 565f4947..981a885d 100644 --- a/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java +++ b/src/test/java/org/cryptomator/cryptofs/dir/DirectoryStreamFactoryTest.java @@ -5,7 +5,11 @@ import org.cryptomator.cryptofs.CryptoPathMapper.CiphertextDirectory; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; import java.io.IOException; @@ -15,6 +19,7 @@ import java.nio.file.FileSystem; import java.nio.file.Path; import java.nio.file.spi.FileSystemProvider; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; @@ -95,12 +100,33 @@ public void testCloseClosesAllNonClosedDirectoryStreams() throws IOException { public void testNewDirectoryStreamAfterClosedThrowsClosedFileSystemException() throws IOException { CryptoPath path = mock(CryptoPath.class); Filter filter = mock(Filter.class); - + inTest.close(); - + Assertions.assertThrows(ClosedFileSystemException.class, () -> { inTest.newDirectoryStream(path, filter); }); } + @DisplayName("CiphertextDirStream only contains files with names at least 26 chars long and ending with .c9r or .c9s") + @ParameterizedTest + @MethodSource("provideFilterExamples") + public void testCiphertextDirStreamFilter(String fileName, boolean expected) { + Path p = Mockito.mock(Path.class); + Mockito.when(p.getFileName()).thenReturn(p); + Mockito.when(p.toString()).thenReturn(fileName); + + boolean actual = inTest.matchesEncryptedContentPattern(p); + + Assertions.assertEquals(expected, actual); + } + + private static Stream provideFilterExamples() { + return Stream.of( // + Arguments.of("foo25____25chars_____.c9r", false), // + Arguments.of("bar25____25chars_____.c9s", false), // + Arguments.of("foo26____26chars______.c9r", true), // + Arguments.of("bar26____26chars______.c9s", true)); + } + }