diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9f706d7d..8396a94f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,7 @@ updates: - "org.apache.maven.plugins:*" - "org.jacoco:jacoco-maven-plugin" - "org.owasp:dependency-check-maven" + - "me.fabriciorby:maven-surefire-junit5-tree-reporter" java-production-dependencies: patterns: - "*" @@ -30,6 +31,7 @@ updates: - "org.mockito:*" - "org.hamcrest:*" - "com.google.jimfs:jimfs" + - "me.fabriciorby:maven-surefire-junit5-tree-reporter" - package-ecosystem: "github-actions" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88f8da8a..da527ddf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,8 @@ name: Build on: - [push] + push: + pull_request_target: + types: [labeled] jobs: build: name: Build and Test @@ -12,7 +14,7 @@ jobs: show-progress: false - uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'maven' - name: Cache SonarCloud packages @@ -42,8 +44,8 @@ jobs: path: target/*.jar - name: Create release if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: token: ${{ secrets.CRYPTOBOT_RELEASE_TOKEN }} generate_release_notes: true - prerelease: true \ No newline at end of file + prerelease: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 237bcac3..9bfa8088 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ jobs: show-progress: false - uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'maven' - name: Initialize CodeQL diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml index 4e7e7d0f..601975f4 100644 --- a/.github/workflows/dependency-check.yml +++ b/.github/workflows/dependency-check.yml @@ -14,7 +14,7 @@ jobs: with: runner-os: 'ubuntu-latest' java-distribution: 'temurin' - java-version: 17 + java-version: 21 secrets: nvd-api-key: ${{ secrets.NVD_API_KEY }} slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/publish-central.yml b/.github/workflows/publish-central.yml index 9eb1b363..07727e4c 100644 --- a/.github/workflows/publish-central.yml +++ b/.github/workflows/publish-central.yml @@ -16,14 +16,12 @@ jobs: show-progress: false - uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'maven' server-id: ossrh # Value of the distributionManagement/repository/id field of the pom.xml server-username: MAVEN_USERNAME # env variable for username in deploy server-password: MAVEN_PASSWORD # env variable for token in deploy - gpg-private-key: ${{ secrets.RELEASES_GPG_PRIVATE_KEY }} # Value of the GPG private key to import - gpg-passphrase: MAVEN_GPG_PASSPHRASE # env variable for GPG private key passphrase - name: Enforce project version ${{ github.event.inputs.tag }} run: mvn versions:set -B -DnewVersion=${{ github.event.inputs.tag }} - name: Deploy @@ -36,4 +34,5 @@ jobs: --add-opens=java.desktop/java.awt.font=ALL-UNNAMED MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} MAVEN_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.RELEASES_GPG_PASSPHRASE }} \ No newline at end of file + MAVEN_GPG_PASSPHRASE: ${{ secrets.RELEASES_GPG_PASSPHRASE }} + MAVEN_GPG_KEY: ${{ secrets.RELEASES_GPG_PRIVATE_KEY }} # Value of the GPG private key to import diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index e0f8b793..59b312e4 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -12,11 +12,9 @@ jobs: show-progress: false - uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'maven' - gpg-private-key: ${{ secrets.RELEASES_GPG_PRIVATE_KEY }} # Value of the GPG private key to import - gpg-passphrase: MAVEN_GPG_PASSPHRASE # env variable for GPG private key passphrase - name: Enforce project version ${{ github.event.release.tag_name }} run: mvn versions:set -B -DnewVersion=${{ github.event.release.tag_name }} - name: Deploy @@ -24,6 +22,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MAVEN_GPG_PASSPHRASE: ${{ secrets.RELEASES_GPG_PASSPHRASE }} + MAVEN_GPG_KEY: ${{ secrets.RELEASES_GPG_PRIVATE_KEY }} # Value of the GPG private key to import - name: Slack Notification uses: rtCamp/action-slack-notify@v2 env: diff --git a/.idea/misc.xml b/.idea/misc.xml index 67e1e611..9dc782bb 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -8,5 +8,5 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 6ed04e88..3050a0db 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Known Vulnerabilities](https://snyk.io/test/github/cryptomator/cryptofs/badge.svg)](https://snyk.io/test/github/cryptomator/cryptofs) **CryptoFS:** Implementation of the [Cryptomator](https://github.com/cryptomator/cryptomator) encryption scheme. +For more info about the encryption scheme, read the [docs](https://docs.cryptomator.org/en/latest/security/vault/). ## Features @@ -98,7 +99,7 @@ For more details on how to use the constructed `FileSystem`, you may consult the ### Dependencies -* Java 17 +* Java 21 * Maven 3 ### Run Maven diff --git a/pom.xml b/pom.xml index 95107f48..a0fc375d 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.cryptomator cryptofs - 2.6.9 + 2.7.0 Cryptomator Crypto Filesystem This library provides the Java filesystem provider used by Cryptomator. https://github.com/cryptomator/cryptofs @@ -15,27 +15,27 @@ UTF-8 - 17 + 21 - 2.1.2 + 2.2.0 4.4.0 - 2.49 - 32.1.3-jre + 2.51.1 + 33.2.1-jre 3.1.8 - 2.0.12 + 2.0.13 - 5.10.2 - 5.2.0 - 2.2 + 5.10.3 + 5.12.0 + 3.0 1.3.0 - 9.0.9 - 1.2.1 - 0.8.11 - 1.6.13 + 10.0.3 + 1.3.0 + 0.8.12 + 1.7.0 @@ -114,7 +114,7 @@ org.mockito - mockito-inline + mockito-core ${mockito.version} test @@ -143,7 +143,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.12.1 + 3.13.0 true @@ -158,7 +158,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.2.5 + 3.3.1 me.fabriciorby @@ -181,11 +181,11 @@ org.apache.maven.plugins maven-jar-plugin - 3.3.0 + 3.4.2 maven-source-plugin - 3.3.0 + 3.3.1 attach-sources @@ -197,7 +197,7 @@ maven-javadoc-plugin - 3.6.3 + 3.8.0 attach-javadocs @@ -252,7 +252,7 @@ true true suppression.xml - ${env.NVD_API_KEY} + NVD_API_KEY @@ -300,7 +300,7 @@ maven-gpg-plugin - 3.1.0 + 3.2.4 sign-artifacts @@ -309,10 +309,7 @@ sign - - --pinentry-mode - loopback - + bc diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 5cbfb094..abe11b30 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -19,6 +19,7 @@ // https://github.com/javax-inject/javax-inject/issues/33 // May be provided by another lib during runtime requires static javax.inject; + requires java.compiler; exports org.cryptomator.cryptofs; exports org.cryptomator.cryptofs.common; diff --git a/src/main/java/org/cryptomator/cryptofs/attr/CryptoBasicFileAttributeView.java b/src/main/java/org/cryptomator/cryptofs/attr/CryptoBasicFileAttributeView.java index 1e44bfa1..f621b770 100644 --- a/src/main/java/org/cryptomator/cryptofs/attr/CryptoBasicFileAttributeView.java +++ b/src/main/java/org/cryptomator/cryptofs/attr/CryptoBasicFileAttributeView.java @@ -48,10 +48,10 @@ public BasicFileAttributes readAttributes() throws IOException { @Override public void setTimes(FileTime lastModifiedTime, FileTime lastAccessTime, FileTime createTime) throws IOException { readonlyFlag.assertWritable(); - getCiphertextAttributeView(BasicFileAttributeView.class).setTimes(lastModifiedTime, lastAccessTime, createTime); if (lastModifiedTime != null) { getOpenCryptoFile().ifPresent(file -> file.setLastModifiedTime(lastModifiedTime)); } + getCiphertextAttributeView(BasicFileAttributeView.class).setTimes(lastModifiedTime, lastAccessTime, createTime); } } diff --git a/src/main/java/org/cryptomator/cryptofs/ch/ChannelCloseListener.java b/src/main/java/org/cryptomator/cryptofs/ch/ChannelCloseListener.java index 4933e1bc..3ee1c008 100644 --- a/src/main/java/org/cryptomator/cryptofs/ch/ChannelCloseListener.java +++ b/src/main/java/org/cryptomator/cryptofs/ch/ChannelCloseListener.java @@ -1,11 +1,9 @@ package org.cryptomator.cryptofs.ch; -import java.io.IOException; - @FunctionalInterface public interface ChannelCloseListener { - void closed(CleartextFileChannel channel) throws IOException; + void closed(CleartextFileChannel channel); } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java index 4723e80b..44d165a8 100644 --- a/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java +++ b/src/main/java/org/cryptomator/cryptofs/ch/CleartextFileChannel.java @@ -29,6 +29,8 @@ import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.FileTime; import java.time.Instant; +import java.util.Iterator; +import java.util.List; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReadWriteLock; @@ -233,7 +235,8 @@ private void forceInternal(boolean metaData) throws IOException { * * @throws IOException */ - private void flush() throws IOException { + @VisibleForTesting + void flush() throws IOException { if (isWritable()) { writeHeaderIfNeeded(); chunkCache.flush(); @@ -322,20 +325,38 @@ long beginOfChunk(long cleartextPos) { @Override protected void implCloseChannel() throws IOException { + var closeActions = List.of(this::flush, // + super::implCloseChannel, // + () -> closeListener.closed(this), // + ciphertextFileChannel::close, // + this::tryPersistLastModified); + tryAll(closeActions.iterator()); + } + + private void tryPersistLastModified() { try { - flush(); - ciphertextFileChannel.force(true); + persistLastModified(); + } catch (NoSuchFileException nsfe) { + //no-op, see https://github.com/cryptomator/cryptofs/issues/169 + } catch (IOException e) { + LOG.warn("Failed to persist last modified timestamp for encrypted file: {}", e.getMessage()); + } + } + + private void tryAll(Iterator actions) throws IOException { + if (actions.hasNext()) { try { - persistLastModified(); - } catch (NoSuchFileException nsfe) { - //no-op, see https://github.com/cryptomator/cryptofs/issues/169 - } catch (IOException e) { - //only best effort attempt - LOG.warn("Failed to persist last modified timestamp for encrypted file: {}", e.getMessage()); + actions.next().run(); + } finally { + tryAll(actions); } - } finally { - super.implCloseChannel(); - closeListener.closed(this); } } + + @FunctionalInterface + private interface CloseAction { + + void run() throws IOException; + } + } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/ChunkIO.java b/src/main/java/org/cryptomator/cryptofs/fh/ChunkIO.java index 8ec707f8..e1e73c63 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/ChunkIO.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/ChunkIO.java @@ -52,21 +52,11 @@ int write(ByteBuffer src, long position) throws IOException { } private FileChannel getReadableChannel() { - Iterator iter = readableChannels.iterator(); - if (iter.hasNext()) { - return iter.next(); - } else { - throw new NonReadableChannelException(); - } + return readableChannels.stream().findFirst().orElseThrow(NonReadableChannelException::new); } private FileChannel getWritableChannel() { - Iterator iter = writableChannels.iterator(); - if (iter.hasNext()) { - return iter.next(); - } else { - throw new NonWritableChannelException(); - } + return writableChannels.stream().findFirst().orElseThrow(NonWritableChannelException::new); } } diff --git a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java index a07b5db2..14e5d072 100644 --- a/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java +++ b/src/main/java/org/cryptomator/cryptofs/fh/OpenCryptoFile.java @@ -183,17 +183,13 @@ public void updateCurrentFilePath(Path newFilePath) { currentFilePath.updateAndGet(p -> p == null ? null : newFilePath); } - private synchronized void channelClosed(CleartextFileChannel cleartextFileChannel) throws IOException { - try { - FileChannel ciphertextFileChannel = openChannels.remove(cleartextFileChannel); - if (ciphertextFileChannel != null) { - chunkIO.unregisterChannel(ciphertextFileChannel); - ciphertextFileChannel.close(); - } - } finally { - if (openChannels.isEmpty()) { - close(); - } + private synchronized void channelClosed(CleartextFileChannel cleartextFileChannel) { + FileChannel ciphertextFileChannel = openChannels.remove(cleartextFileChannel); + if (ciphertextFileChannel != null) { + chunkIO.unregisterChannel(ciphertextFileChannel); + } + if (openChannels.isEmpty()) { + close(); } } diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java index e694130f..5095b5cd 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoFileSystemImplTest.java @@ -72,7 +72,6 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -959,6 +958,7 @@ public void copyDirectory() throws IOException { when(physicalFsProv.newFileChannel(Mockito.eq(ciphertextDestinationDirFile), Mockito.any(), any(FileAttribute[].class))).thenReturn(ciphertextTargetDirDirFileFileChannel); when(cryptoPathMapper.getCiphertextFileType(cleartextSource)).thenReturn(CiphertextFileType.DIRECTORY); when(cryptoPathMapper.getCiphertextFileType(cleartextDestination)).thenThrow(NoSuchFileException.class); + when(physicalFsProv.exists(ciphertextTargetParent)).thenReturn(true); Mockito.doThrow(new NoSuchFileException("ciphertextDestinationDirFile")).when(physicalFsProv).checkAccess(ciphertextDestinationFile); inTest.copy(cleartextSource, cleartextDestination); @@ -1007,6 +1007,7 @@ public void moveDirectoryCopyBasicAttributes() throws IOException { when(physicalFsProv.readAttributes(Mockito.same(ciphertextSourceDir), Mockito.same(BasicFileAttributes.class), any(LinkOption[].class))).thenReturn(srcAttrs); when(physicalFsProv.getFileAttributeView(Mockito.same(ciphertextDestinationDir), Mockito.same(BasicFileAttributeView.class), any(LinkOption[].class))).thenReturn(dstAttrView); when(physicalFsProv.newFileChannel(Mockito.same(ciphertextDestinationDirFile), Mockito.anySet(), any(FileAttribute[].class))).thenReturn(ciphertextTargetDirDirFileFileChannel); + when(physicalFsProv.exists(ciphertextTargetParent)).thenReturn(true); inTest.copy(cleartextSource, cleartextDestination, StandardCopyOption.COPY_ATTRIBUTES); @@ -1027,6 +1028,7 @@ public void moveDirectoryCopyFileOwnerAttributes() throws IOException { when(physicalFsProv.getFileAttributeView(Mockito.same(ciphertextSourceDir), Mockito.same(FileOwnerAttributeView.class), any(LinkOption[].class))).thenReturn(srcAttrsView); when(physicalFsProv.getFileAttributeView(Mockito.same(ciphertextDestinationDir), Mockito.same(FileOwnerAttributeView.class), any(LinkOption[].class))).thenReturn(dstAttrView); when(physicalFsProv.newFileChannel(Mockito.same(ciphertextDestinationDirFile), Mockito.anySet(), any(FileAttribute[].class))).thenReturn(ciphertextTargetDirDirFileFileChannel); + when(physicalFsProv.exists(ciphertextTargetParent)).thenReturn(true); inTest.copy(cleartextSource, cleartextDestination, StandardCopyOption.COPY_ATTRIBUTES); @@ -1050,6 +1052,7 @@ public void moveDirectoryCopyPosixAttributes() throws IOException { when(physicalFsProv.readAttributes(Mockito.same(ciphertextSourceDir), Mockito.same(PosixFileAttributes.class), any(LinkOption[].class))).thenReturn(srcAttrs); when(physicalFsProv.getFileAttributeView(Mockito.same(ciphertextDestinationDir), Mockito.same(PosixFileAttributeView.class), any(LinkOption[].class))).thenReturn(dstAttrView); when(physicalFsProv.newFileChannel(Mockito.same(ciphertextDestinationDirFile), Mockito.anySet(), any(FileAttribute[].class))).thenReturn(ciphertextTargetDirDirFileFileChannel); + when(physicalFsProv.exists(ciphertextTargetParent)).thenReturn(true); inTest.copy(cleartextSource, cleartextDestination, StandardCopyOption.COPY_ATTRIBUTES); @@ -1073,6 +1076,7 @@ public void moveDirectoryCopyDosAttributes() throws IOException { when(physicalFsProv.readAttributes(Mockito.same(ciphertextSourceDir), Mockito.same(DosFileAttributes.class), any(LinkOption[].class))).thenReturn(srcAttrs); when(physicalFsProv.getFileAttributeView(Mockito.same(ciphertextDestinationDir), Mockito.same(DosFileAttributeView.class), any(LinkOption[].class))).thenReturn(dstAttrView); when(physicalFsProv.newFileChannel(Mockito.same(ciphertextDestinationDirFile), Mockito.anySet(), any(FileAttribute[].class))).thenReturn(ciphertextTargetDirDirFileFileChannel); + when(physicalFsProv.exists(ciphertextTargetParent)).thenReturn(true); inTest.copy(cleartextSource, cleartextDestination, StandardCopyOption.COPY_ATTRIBUTES); @@ -1168,6 +1172,7 @@ public void createDirectoryIfPathCiphertextFileDoesExistThrowsFileAlreadyExcepti when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("foo", ciphertextParent)); when(ciphertextParent.getFileSystem()).thenReturn(fileSystem); doThrow(new FileAlreadyExistsException(path.toString())).when(cryptoPathMapper).assertNonExisting(path); + when(provider.exists(ciphertextParent)).thenReturn(true); FileAlreadyExistsException e = Assertions.assertThrows(FileAlreadyExistsException.class, () -> { inTest.createDirectory(path); @@ -1177,7 +1182,7 @@ public void createDirectoryIfPathCiphertextFileDoesExistThrowsFileAlreadyExcepti @Test public void createDirectoryCreatesDirectoryIfConditonsAreMet() throws IOException { - Path ciphertextParent = mock(Path.class, "ciphertextParent"); + Path ciphertextParent = mock(Path.class, "d/00/00"); 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/"); @@ -1187,7 +1192,7 @@ public void createDirectoryCreatesDirectoryIfConditonsAreMet() throws IOExceptio when(ciphertextRawPath.resolve("dir.c9r")).thenReturn(ciphertextDirFile); when(cryptoPathMapper.getCiphertextFilePath(path)).thenReturn(ciphertextPath); when(cryptoPathMapper.getCiphertextDir(path)).thenReturn(new CiphertextDirectory(dirId, ciphertextDirPath)); - when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextDirPath)); + when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextParent)); when(cryptoPathMapper.getCiphertextFileType(path)).thenThrow(NoSuchFileException.class); when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath); when(ciphertextPath.getDirFilePath()).thenReturn(ciphertextDirFile); @@ -1197,6 +1202,7 @@ public void createDirectoryCreatesDirectoryIfConditonsAreMet() throws IOExceptio 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); + when(provider.exists(ciphertextParent)).thenReturn(true); inTest.createDirectory(path); @@ -1206,7 +1212,7 @@ public void createDirectoryCreatesDirectoryIfConditonsAreMet() throws IOExceptio @Test public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() throws IOException { - Path ciphertextParent = mock(Path.class, "ciphertextParent"); + Path ciphertextParent = mock(Path.class, "d/00/00"); 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/"); @@ -1216,7 +1222,7 @@ public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() thro when(ciphertextRawPath.resolve("dir.c9r")).thenReturn(ciphertextDirFile); when(cryptoPathMapper.getCiphertextFilePath(path)).thenReturn(ciphertextPath); when(cryptoPathMapper.getCiphertextDir(path)).thenReturn(new CiphertextDirectory(dirId, ciphertextDirPath)); - when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextDirPath)); + when(cryptoPathMapper.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextParent)); when(cryptoPathMapper.getCiphertextFileType(path)).thenThrow(NoSuchFileException.class); when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath); when(ciphertextPath.getDirFilePath()).thenReturn(ciphertextDirFile); @@ -1226,15 +1232,18 @@ public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() thro 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); + when(provider.exists(ciphertextParent)).thenReturn(true); - // make createDirectory with an FileSystemException during Files.createDirectories(ciphertextDirPath) - doThrow(new IOException()).when(provider).createDirectory(ciphertextDirPath); + // make createDirectory with an FileSystemException during Files.createDirectories(ciphertextContentDir) + doThrow(new IOException()).when(provider).readAttributesIfExists(ciphertextDirPath, BasicFileAttributes.class); + doThrow(new FileAlreadyExistsException("very specific")).when(provider).createDirectory(ciphertextDirPath); when(ciphertextDirPath.toAbsolutePath()).thenReturn(ciphertextDirPath); when(ciphertextDirPath.getParent()).thenReturn(null); - Assertions.assertThrows(IOException.class, () -> { + var exception = Assertions.assertThrows(FileAlreadyExistsException.class, () -> { inTest.createDirectory(path); }); + Assertions.assertEquals("very specific", exception.getMessage()); verify(readonlyFlag).assertWritable(); verify(provider).delete(ciphertextDirFile); verify(dirIdProvider).delete(ciphertextDirFile); @@ -1243,7 +1252,7 @@ public void createDirectoryClearsDirIdAndDeletesDirFileIfCreatingDirFails() thro @Test public void createDirectoryBackupsDirIdInCiphertextDirPath() throws IOException { - Path ciphertextParent = mock(Path.class, "ciphertextParent"); + Path ciphertextParent = mock(Path.class, "d/00/00"); 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/"); @@ -1254,7 +1263,7 @@ public void createDirectoryBackupsDirIdInCiphertextDirPath() throws IOException 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.getCiphertextDir(parent)).thenReturn(new CiphertextDirectory("parentDirId", ciphertextParent)); when(cryptoPathMapper.getCiphertextFileType(path)).thenThrow(NoSuchFileException.class); when(ciphertextPath.getRawPath()).thenReturn(ciphertextRawPath); when(ciphertextPath.getDirFilePath()).thenReturn(ciphertextDirFile); @@ -1264,6 +1273,7 @@ public void createDirectoryBackupsDirIdInCiphertextDirPath() throws IOException 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); + when(provider.exists(ciphertextParent)).thenReturn(true); inTest.createDirectory(path); diff --git a/src/test/java/org/cryptomator/cryptofs/CryptoPathMapperTest.java b/src/test/java/org/cryptomator/cryptofs/CryptoPathMapperTest.java index e2da36a8..32241fb8 100644 --- a/src/test/java/org/cryptomator/cryptofs/CryptoPathMapperTest.java +++ b/src/test/java/org/cryptomator/cryptofs/CryptoPathMapperTest.java @@ -290,6 +290,7 @@ public void testGetCiphertextFileTypeForDirectory() throws IOException { Mockito.when(underlyingFileSystemProvider.readAttributes(dirFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenReturn(Mockito.mock(BasicFileAttributes.class)); Mockito.when(underlyingFileSystemProvider.readAttributes(symlinkFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenThrow(NoSuchFileException.class); Mockito.when(underlyingFileSystemProvider.readAttributes(contentsFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenThrow(NoSuchFileException.class); + Mockito.when(underlyingFileSystemProvider.exists(dirFilePath, LinkOption.NOFOLLOW_LINKS)).thenReturn(true); CryptoPathMapper mapper = new CryptoPathMapper(pathToVault, cryptor, dirIdProvider, longFileNameProvider, vaultConfig); @@ -305,6 +306,7 @@ public void testGetCiphertextFileTypeForSymlink() throws IOException { Mockito.when(underlyingFileSystemProvider.readAttributes(dirFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenThrow(NoSuchFileException.class); Mockito.when(underlyingFileSystemProvider.readAttributes(symlinkFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenReturn(Mockito.mock(BasicFileAttributes.class)); Mockito.when(underlyingFileSystemProvider.readAttributes(contentsFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenThrow(NoSuchFileException.class); + Mockito.when(underlyingFileSystemProvider.exists(symlinkFilePath, LinkOption.NOFOLLOW_LINKS)).thenReturn(true); CryptoPathMapper mapper = new CryptoPathMapper(pathToVault, cryptor, dirIdProvider, longFileNameProvider, vaultConfig); @@ -322,6 +324,7 @@ public void testGetCiphertextFileTypeForShortenedFile() throws IOException { Mockito.when(underlyingFileSystemProvider.readAttributes(contentsFilePath, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)).thenReturn(Mockito.mock(BasicFileAttributes.class)); Mockito.when(fileNameCryptor.encryptFilename(Mockito.any(), Mockito.eq("LONGCLEAR"), Mockito.any())).thenReturn(Strings.repeat("A", 1000)); Mockito.when(longFileNameProvider.deflate(Mockito.any())).thenReturn(new LongFileNameProvider.DeflatedFileName(c9rPath, null, null)); + Mockito.when(underlyingFileSystemProvider.exists(contentsFilePath, LinkOption.NOFOLLOW_LINKS)).thenReturn(true); CryptoPathMapper mapper = new CryptoPathMapper(pathToVault, cryptor, dirIdProvider, longFileNameProvider, vaultConfig); diff --git a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java index 89213ddf..a19e508d 100644 --- a/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java +++ b/src/test/java/org/cryptomator/cryptofs/ch/CleartextFileChannelTest.java @@ -43,7 +43,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; -import java.util.function.Supplier; import static org.hamcrest.CoreMatchers.is; import static org.mockito.ArgumentMatchers.any; @@ -71,7 +70,7 @@ public class CleartextFileChannelTest { private FileHeaderHolder headerHolder = mock(FileHeaderHolder.class); private AtomicBoolean headerIsPersisted = mock(AtomicBoolean.class); private EffectiveOpenOptions options = mock(EffectiveOpenOptions.class); - private Path filePath = Mockito.mock(Path.class,"/foo/bar"); + private Path filePath = Mockito.mock(Path.class, "/foo/bar"); private AtomicReference currentFilePath = new AtomicReference<>(filePath); private AtomicLong fileSize = new AtomicLong(100); private AtomicReference lastModified = new AtomicReference<>(Instant.ofEpochMilli(0)); @@ -98,7 +97,7 @@ public void setUp() throws IOException { var fsProvider = Mockito.mock(FileSystemProvider.class); when(filePath.getFileSystem()).thenReturn(fs); when(fs.provider()).thenReturn(fsProvider); - when(fsProvider.getFileAttributeView(filePath,BasicFileAttributeView.class)).thenReturn(attributeView); + when(fsProvider.getFileAttributeView(filePath, BasicFileAttributeView.class)).thenReturn(attributeView); when(readWriteLock.readLock()).thenReturn(readLock); when(readWriteLock.writeLock()).thenReturn(writeLock); @@ -236,20 +235,26 @@ public void testForceWithoutMetadataDoesntUpdatesLastModifiedTime() throws IOExc public class Close { @Test - public void testCloseTriggersCloseListener() throws IOException { - inTest.implCloseChannel(); + @DisplayName("IOException during flush cleans up, persists lastModified and rethrows") + public void testCloseIoExceptionFlush() throws IOException { + var inSpy = Mockito.spy(inTest); + Mockito.doThrow(IOException.class).when(inSpy).flush(); + + Assertions.assertThrows(IOException.class, () -> inSpy.implCloseChannel()); - verify(closeListener).closed(inTest); + verify(closeListener).closed(inSpy); + verify(ciphertextFileChannel).close(); + verify(inSpy).persistLastModified(); } @Test - @DisplayName("On close, first flush channel, then persist lastModified") - public void testCloseFlushBeforePersist() throws IOException { + @DisplayName("On close, first close channel, then persist lastModified") + public void testCloseCipherChannelCloseBeforePersist() throws IOException { var inSpy = spy(inTest); inSpy.implCloseChannel(); var ordering = inOrder(inSpy, ciphertextFileChannel); - ordering.verify(ciphertextFileChannel).force(true); + ordering.verify(ciphertextFileChannel).close(); ordering.verify(inSpy).persistLastModified(); } @@ -264,6 +269,20 @@ public void testCloseUpdatesLastModifiedTimeIfWriteable() throws IOException { verify(attributeView).setTimes(Mockito.eq(fileTime), Mockito.any(), Mockito.isNull()); } + @Test + @DisplayName("IOException on persisting lastModified during close is ignored") + public void testCloseExceptionOnLastModifiedPersistenceIgnored() throws IOException { + when(options.writable()).thenReturn(true); + lastModified.set(Instant.ofEpochMilli(123456789000l)); + + var inSpy = Mockito.spy(inTest); + Mockito.doThrow(IOException.class).when(inSpy).persistLastModified(); + + Assertions.assertDoesNotThrow(() -> inSpy.implCloseChannel()); + verify(closeListener).closed(inSpy); + verify(ciphertextFileChannel).close(); + } + @Test public void testCloseDoesNotUpdateLastModifiedTimeIfReadOnly() throws IOException { when(options.writable()).thenReturn(false);